diff --git a/LICENSE b/LICENSE index b28f92c..d645695 100644 --- a/LICENSE +++ b/LICENSE @@ -1,17 +1,202 @@ -Apache License -Version 2.0, January 2004 -http://www.apache.org/licenses/ -Copyright 2026 LoopGrid + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - http://www.apache.org/licenses/LICENSE-2.0 + 1. Definitions. -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index 18ca242..35d6215 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,7 @@ Verify a LoopGrid evidence bundle directly in GitHub Actions. -The action runs LoopGrid's offline verifier and fails CI when evidence integrity -verification fails. +The action installs the canonical [`loopgrid-verify`](https://pypi.org/project/loopgrid-verify/) package and fails CI when evidence integrity verification fails. The Action does not carry a separate verifier implementation. ## Quick start @@ -17,25 +16,23 @@ jobs: verify: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v4 - name: Verify evidence - uses: loopgridio/loopgrid-evidence-verify@v1.0.0 + uses: loopgridio/loopgrid-evidence-verify@v1 with: evidence: ./evidence/evidence.zip ``` ## Pin signer identity -An embedded public key can establish integrity under that key, but it does not by -itself establish that the key belongs to the signer you intended to trust. +An embedded public key can establish integrity under that key, but it does not by itself establish that the key belongs to the signer you intended to trust. -For stronger signer identity verification, pass either `expected-key-id` or an -out-of-band trusted public key: +For stronger signer identity verification, pass either `expected-key-id` or an out-of-band trusted public key: ```yaml - name: Verify evidence with trusted signer - uses: loopgridio/loopgrid-evidence-verify@v1.0.0 + uses: loopgridio/loopgrid-evidence-verify@v1 with: evidence: ./evidence/evidence.zip trusted-public-key: ./keys/loopgrid-production-public-key.pem @@ -52,28 +49,32 @@ out-of-band trusted public key: ## What verification checks -Depending on what is present in the evidence bundle, the canonical LoopGrid -verifier checks cryptographic signatures, content commitments, hash-chain -continuity, signer identity metadata, disclosures, checkpoints, and optional -RFC3161 timestamp evidence. +Depending on what is present in the evidence bundle, the canonical LoopGrid verifier checks: + +- signed Evidence Bundle v2 file attestations; +- SHA-256 digests for exported evidence files; +- attestation digest and signature validity; +- event signatures and payload commitments; +- workspace hash-chain continuity; +- signer identity metadata and optional out-of-band key pinning; +- disclosures and policy consistency; +- checkpoints and optional RFC3161 timestamp evidence. + +Legacy unattested Evidence Bundle v2 exports remain ledger-verifiable with an explicit warning that exported file bytes are not covered by a signed bundle attestation. ## Test fixtures -This repository includes two **synthetic CI fixtures** generated only to test the -public verifier: +This repository includes synthetic CI fixtures generated only to test the public verifier: -- `test-fixtures/valid-evidence.zip` — expected to verify successfully -- `test-fixtures/tampered-evidence.zip` — contains a modified protected payload and - is expected to fail verification -- `test-fixtures/trusted-public-key.pem` — public key for the synthetic fixture +- `test-fixtures/valid-evidence.zip` — signed, attested bundle expected to verify successfully; +- `test-fixtures/tampered-evidence.zip` — same style of bundle with a modified `report.html`, expected to fail with bundle file digest mismatch; +- `test-fixtures/trusted-public-key.pem` — public key for the synthetic fixture. These are not customer data and are not production evidence samples. ## Trust boundary -Successful verification proves the integrity/provenance properties implemented -by the verifier for the evidence supplied. It does not prove that an AI decision -was correct, lawful, or that every real-world event was captured. +Successful verification proves the integrity/provenance properties implemented by the verifier for the evidence supplied. It does not prove that an AI decision was correct or establish legal compliance. ## License diff --git a/action.yml b/action.yml index d980cb8..0276435 100644 --- a/action.yml +++ b/action.yml @@ -34,11 +34,11 @@ outputs: runs: using: "composite" steps: - - name: Install verifier dependencies + - name: Install canonical LoopGrid verifier shell: bash run: | set -euo pipefail - python3 -m pip install --disable-pip-version-check -q -r "$GITHUB_ACTION_PATH/requirements.txt" + python3 -m pip install --disable-pip-version-check -q "loopgrid-verify==0.1.0" - name: Verify LoopGrid evidence id: verify @@ -56,13 +56,6 @@ runs: exit 2 fi - VERIFIER="$GITHUB_ACTION_PATH/verifier/loopgrid_verify.py" - - if [ ! -f "$VERIFIER" ]; then - echo "::error::LoopGrid verifier is missing: $VERIFIER" - exit 2 - fi - args=("$INPUT_EVIDENCE") if [ -n "$INPUT_EXPECTED_KEY_ID" ]; then @@ -77,7 +70,6 @@ runs: args+=(--tsa-ca-file "$INPUT_TSA_CA_FILE") fi - echo "LoopGrid Evidence Verification" - python3 "$VERIFIER" "${args[@]}" + loopgrid-verify "${args[@]}" echo "result=VERIFIED" >> "$GITHUB_OUTPUT" diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 0bffe4e..0000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -cryptography>=42,<47 -asn1crypto>=1.5,<2 diff --git a/test-fixtures/tampered-evidence.zip b/test-fixtures/tampered-evidence.zip index 8acf622..3998a75 100644 Binary files a/test-fixtures/tampered-evidence.zip and b/test-fixtures/tampered-evidence.zip differ diff --git a/test-fixtures/trusted-public-key.pem b/test-fixtures/trusted-public-key.pem index 86dbf49..92d5dc5 100644 --- a/test-fixtures/trusted-public-key.pem +++ b/test-fixtures/trusted-public-key.pem @@ -1,3 +1,3 @@ -----BEGIN PUBLIC KEY----- -MCowBQYDK2VwAyEAkm1vgdl8QjzzIvCr/w2nPHP81ryQ2DPLWGMilHgE9CA= +MCowBQYDK2VwAyEAv4xdHZpbv1/XlWqBkgwux4MkJLxIiToyKjr3Bs41rSY= -----END PUBLIC KEY----- diff --git a/test-fixtures/valid-evidence.zip b/test-fixtures/valid-evidence.zip index fcc3c57..7f0e577 100644 Binary files a/test-fixtures/valid-evidence.zip and b/test-fixtures/valid-evidence.zip differ diff --git a/verifier/README.md b/verifier/README.md deleted file mode 100644 index 5f7af7e..0000000 --- a/verifier/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# Canonical verifier required - -Before publishing this GitHub Action, copy the **canonical current LoopGrid v0.8** -offline verifier into this directory with the exact filename: - -`loopgrid_verify.py` - -Do not use the website's synthetic-demo verifier (`verify_synthetic.py`) here. -The Marketplace action must verify the real LoopGrid evidence-bundle format. - -Expected invocation, based on the current LoopGrid product documentation: - -```bash -python3 verifier/loopgrid_verify.py evidence.zip -``` - -The verifier should exit with code 0 only when verification succeeds and with a -non-zero code when integrity/signature/chain verification fails. - -If the current verifier does not yet use exit codes this way, adapt the wrapper -only after inspecting the real verifier's output contract. diff --git a/verifier/loopgrid_verify.py b/verifier/loopgrid_verify.py deleted file mode 100644 index 01479b7..0000000 --- a/verifier/loopgrid_verify.py +++ /dev/null @@ -1,233 +0,0 @@ -from __future__ import annotations -import argparse,base64,hashlib,json,subprocess,tempfile,zipfile -from pathlib import Path -from cryptography.hazmat.primitives import hashes,serialization -from cryptography.hazmat.primitives.asymmetric import ec,utils -from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey - - -def canonical_json(obj)->bytes: - return json.dumps(obj,sort_keys=True,separators=(",",":"),ensure_ascii=False,allow_nan=False).encode("utf-8") - -def sha256_hex(data:bytes)->str:return hashlib.sha256(data).hexdigest() - -def _lines(z,name): - try:return [json.loads(x) for x in z.read(name).decode().splitlines() if x.strip()] - except KeyError:return [] - -def _json_file(z,name,default=None): - try:return json.loads(z.read(name)) - except KeyError:return default - -def _policy_digest(policy,workspace_id=None): - if not isinstance(policy,dict):return None - required={ - "workspace_id":policy.get("workspace_id") or workspace_id, - "policy_id":policy.get("policy_id"), - "version":policy.get("version"), - "rule":policy.get("rule"), - } - return sha256_hex(canonical_json(required)) - - -def _public_key_id(public)->str: - if isinstance(public,Ed25519PublicKey): - raw=public.public_bytes(serialization.Encoding.Raw,serialization.PublicFormat.Raw) - return "ed25519:"+sha256_hex(raw)[:16] - if isinstance(public,ec.EllipticCurvePublicKey): - pem=public.public_bytes(serialization.Encoding.PEM,serialization.PublicFormat.SubjectPublicKeyInfo) - return "aws-kms:"+sha256_hex(pem)[:16] - return "unknown:"+sha256_hex(public.public_bytes(serialization.Encoding.DER,serialization.PublicFormat.SubjectPublicKeyInfo))[:16] - - -def _verify_sig(public,algorithm,hex_hash,signature_b64): - try: - sig=base64.b64decode(signature_b64);digest=bytes.fromhex(hex_hash) - if algorithm=="Ed25519": - if not isinstance(public,Ed25519PublicKey):return False - public.verify(sig,digest);return True - if algorithm=="ECDSA_SHA_256": - if not isinstance(public,ec.EllipticCurvePublicKey):return False - public.verify(sig,digest,ec.ECDSA(utils.Prehashed(hashes.SHA256())));return True - return False - except Exception:return False - - -def _verify_rfc3161_imprint(raw: bytes, expected_digest: str) -> dict: - """Validate RFC3161 status, SHA-256 algorithm and message imprint without OpenSSL.""" - try: - from asn1crypto import tsp - - resp = tsp.TimeStampResp.load(raw) - status = resp["status"]["status"].native - if status not in {"granted", "granted_with_mods"}: - return {"valid": False, "status": status, "reason": "timestamp_status_not_granted"} - token = resp["time_stamp_token"] - content = token["content"]["encap_content_info"]["content"] - info = content.parsed - imprint = info["message_imprint"]["hashed_message"].native.hex() - algorithm = info["message_imprint"]["hash_algorithm"]["algorithm"].native - valid = algorithm == "sha256" and imprint.lower() == expected_digest.lower() - return { - "valid": valid, - "status": status, - "algorithm": algorithm, - "imprint": imprint, - "gen_time": str(info["gen_time"].native), - } - except Exception as exc: - return {"valid": False, "reason": "timestamp_parse_error", "detail": type(exc).__name__} - - -def _signed_body(e): - return {k:e.get(k) for k in ["event_id","decision_id","workspace_id","event_type","occurred_at","actor","privacy_mode","payload_commitment","payload"]} - - -def verify_bundle(path:str,tsa_ca_file:str|None=None,expected_key_id:str|None=None,trusted_public_key:str|None=None)->dict: - failures=[];warnings=[] - with zipfile.ZipFile(path) as z: - manifest=json.loads(z.read('manifest.json')) - events=_lines(z,'events.jsonl');witnesses=_lines(z,'chain-witness.jsonl');disclosures=_lines(z,'disclosures.jsonl') - signer_doc=_json_file(z,'signer.json') - verification_doc=_json_file(z,'verification.json') - lifecycle_doc=_json_file(z,'lifecycle.json') - policy_doc=_json_file(z,'policy/policy.json') - bundled_pem=z.read('public-key.pem');public=serialization.load_pem_public_key(bundled_pem) - computed_key_id=_public_key_id(public);manifest_key_id=(manifest.get('signer') or {}).get('key_id') or (manifest.get('integrity') or {}).get('key_id') - bundle_schema=manifest.get('bundle_schema') - if bundle_schema and bundle_schema!='loopgrid/evidence-bundle/2': - warnings.append({"reason":"unknown_bundle_schema","bundle_schema":bundle_schema}) - if signer_doc and signer_doc.get('key_id') and signer_doc.get('key_id')!=computed_key_id: - failures.append({"reason":"signer_document_key_id_mismatch","signer_key_id":signer_doc.get('key_id'),"computed_key_id":computed_key_id}) - if lifecycle_doc is not None and manifest.get('lifecycle') is not None and lifecycle_doc!=manifest.get('lifecycle'): - failures.append({"reason":"lifecycle_document_mismatch"}) - if verification_doc and verification_doc.get('decision_id') not in {None,manifest.get('decision_id')}: - failures.append({"reason":"verification_document_decision_mismatch"}) - if policy_doc: - digest=_policy_digest(policy_doc,manifest.get('workspace_id')) - declared=policy_doc.get('policy_digest') - manifest_digest=(manifest.get('policy') or {}).get('policy_digest') - if declared and digest!=declared: - failures.append({"reason":"policy_digest_mismatch","computed":digest,"declared":declared}) - if manifest_digest and digest!=manifest_digest: - failures.append({"reason":"manifest_policy_digest_mismatch","computed":digest,"manifest":manifest_digest}) - if manifest_key_id and computed_key_id!=manifest_key_id: - failures.append({"reason":"manifest_public_key_id_mismatch","manifest_key_id":manifest_key_id,"computed_key_id":computed_key_id}) - if expected_key_id and computed_key_id!=expected_key_id: - failures.append({"reason":"expected_key_id_mismatch","expected_key_id":expected_key_id,"computed_key_id":computed_key_id}) - trusted_key_match=None - if trusted_public_key: - try: - trusted=serialization.load_pem_public_key(Path(trusted_public_key).read_bytes()) - trusted_der=trusted.public_bytes(serialization.Encoding.DER,serialization.PublicFormat.SubjectPublicKeyInfo) - bundle_der=public.public_bytes(serialization.Encoding.DER,serialization.PublicFormat.SubjectPublicKeyInfo) - trusted_key_match=trusted_der==bundle_der - if not trusted_key_match:failures.append({"reason":"trusted_public_key_mismatch"}) - except Exception as e: - failures.append({"reason":"trusted_public_key_unreadable","detail":type(e).__name__}) - - # Verify target events and opaque continuity witnesses in workspace-ledger order. - nodes=[] - for e in events:nodes.append({"seq":e['seq'],"event":e,"proof":e['proof'],"opaque":False}) - for w in witnesses:nodes.append({"seq":w['seq'],"event":None,"proof":w['proof'],"opaque":True}) - nodes.sort(key=lambda n:n['seq']) - prev=None - for node in nodes: - proof=node['proof'];seq=node['seq'];alg=proof.get('signature_algorithm') or manifest.get('integrity',{}).get('signature_algorithm') or manifest.get('signer',{}).get('algorithm') or 'Ed25519' - proof_key_id=proof.get('key_id') - if proof_key_id and proof_key_id!=computed_key_id: - failures.append({"seq":seq,"reason":"proof_key_id_mismatch","proof_key_id":proof_key_id,"computed_key_id":computed_key_id}) - if not node['opaque']: - e=node['event'];expected_content=sha256_hex(canonical_json(_signed_body(e))) - if expected_content!=proof.get('content_hash'):failures.append({"seq":seq,"reason":"content_hash_mismatch"}) - try:expected_chain=sha256_hex(bytes.fromhex(proof.get('previous_chain_hash','0'*64))+bytes.fromhex(expected_content)) - except Exception: - expected_chain='';failures.append({"seq":seq,"reason":"malformed_chain_hash"}) - if expected_chain!=proof.get('chain_hash'):failures.append({"seq":seq,"reason":"chain_hash_mismatch"}) - if prev is not None and proof.get('previous_chain_hash')!=prev:failures.append({"seq":seq,"reason":"previous_chain_hash_mismatch"}) - if not _verify_sig(public,alg,proof.get('chain_hash',''),proof.get('signature','')):failures.append({"seq":seq,"reason":"signature_invalid","algorithm":alg}) - prev=proof.get('chain_hash') - - # Verify optional full-payload disclosures against the commitments sealed in events. - by_id={e['event_id']:e for e in events} - for d in disclosures: - e=by_id.get(d.get('event_id')) - if not e:failures.append({"event_id":d.get('event_id'),"reason":"orphan_disclosure"});continue - digest=sha256_hex(canonical_json(d.get('payload'))) - if digest!=e.get('payload_commitment') or digest!=d.get('payload_commitment'):failures.append({"event_id":d.get('event_id'),"reason":"disclosure_commitment_mismatch"}) - - # Checkpoint proof + RFC3161 timestamp. Imprint validation is portable; optional - # certificate-chain trust verification still uses OpenSSL when a CA bundle is supplied. - cp=manifest.get('checkpoint') or {} - checkpoint={"present":bool(cp),"signature_valid":None,"linked_to_bundle_chain":None} - if cp: - cp_digest=cp.get('chain_hash') - cp_sig=cp.get('signature') - cp_alg=cp.get('signature_algorithm') or manifest.get('signer',{}).get('algorithm') - cp_key=cp.get('key_id') - if cp_key and cp_key!=computed_key_id: - failures.append({"reason":"checkpoint_key_id_mismatch","checkpoint_key_id":cp_key,"computed_key_id":computed_key_id}) - checkpoint['signature_valid']=bool(cp_digest and cp_sig and _verify_sig(public,cp_alg,cp_digest,cp_sig)) - if not checkpoint['signature_valid']: - failures.append({"reason":"checkpoint_signature_invalid"}) - cp_seq=cp.get('ledger_seq') - matched=next((n for n in nodes if n.get('seq')==cp_seq),None) - checkpoint['linked_to_bundle_chain']=bool(matched and matched.get('proof',{}).get('chain_hash')==cp_digest) - if not checkpoint['linked_to_bundle_chain']: - warnings.append({"reason":"checkpoint_not_linked_to_bundle_chain","detail":"Bundle remains event-verifiable, but this checkpoint is not bridged by included nodes."}) - - timestamp={"present":'timestamp.tsr' in z.namelist(),"imprint_valid":None,"trust_validated":None} - if timestamp['present']: - digest=cp.get('chain_hash') if cp else None - if not digest: - failures.append({"reason":"timestamp_checkpoint_missing"}) - else: - imprint_result=_verify_rfc3161_imprint(z.read('timestamp.tsr'),digest) - timestamp['imprint_valid']=imprint_result.get('valid') is True - timestamp['status']=imprint_result.get('status') - timestamp['algorithm']=imprint_result.get('algorithm') - timestamp['gen_time']=imprint_result.get('gen_time') - if not timestamp['imprint_valid']: - failures.append({"reason":"timestamp_imprint_invalid","detail":imprint_result.get('reason') or 'imprint mismatch'}) - - if timestamp['present'] and tsa_ca_file: - try: - digest=cp.get('chain_hash') - if not digest:raise ValueError('checkpoint chain hash missing') - with tempfile.NamedTemporaryFile(suffix='.tsr',delete=False) as f:f.write(z.read('timestamp.tsr'));ts_path=f.name - r=subprocess.run(['openssl','ts','-verify','-digest',digest,'-in',ts_path,'-CAfile',tsa_ca_file],capture_output=True,text=True,timeout=10) - timestamp['trust_validated']=r.returncode==0 - if r.returncode!=0:failures.append({"reason":"timestamp_trust_invalid","detail":r.stderr[-300:]}) - except Exception as e:warnings.append({"reason":"timestamp_trust_not_checked","detail":type(e).__name__}) - elif timestamp['present']: - warnings.append({"reason":"timestamp_present_trust_not_checked","detail":"RFC3161 imprint is valid; pass --tsa-ca-file for signer certificate-chain trust validation."}) - - key_identity={ - "computed_key_id":computed_key_id, - "manifest_key_id":manifest_key_id, - "manifest_match":not manifest_key_id or computed_key_id==manifest_key_id, - "expected_key_id":expected_key_id, - "expected_key_match":None if expected_key_id is None else computed_key_id==expected_key_id, - "trusted_public_key_supplied":bool(trusted_public_key), - "trusted_public_key_match":trusted_key_match, - "trust_note":"An embedded public key proves bundle integrity under that key. Pin --expected-key-id or --trusted-public-key when signer identity/authenticity must be established out of band." - } - return {"valid":not failures,"events":len(events),"witnesses":len(witnesses),"disclosures":len(disclosures),"failures":failures,"warnings":warnings,"decision_id":manifest.get('decision_id'),"workspace_id":manifest.get('workspace_id'),"software_version":manifest.get('software_version'),"evidence_profile":manifest.get('version'),"bundle_schema":manifest.get('bundle_schema') or 'legacy','signature_algorithm':manifest.get('signer',{}).get('algorithm') or manifest.get('integrity',{}).get('signature_algorithm'),"key_identity":key_identity,"checkpoint":checkpoint,"timestamp":timestamp,"lifecycle":manifest.get('lifecycle'),"policy_digest":(manifest.get('policy') or {}).get('policy_digest')} - - -def main(): - ap=argparse.ArgumentParser(description='Offline verifier for LoopGrid evidence bundles') - ap.add_argument('bundle') - ap.add_argument('--tsa-ca-file',default=None,help='Trusted CA bundle for RFC3161 signer validation via OpenSSL') - ap.add_argument('--expected-key-id',default=None,help='Pin the expected LoopGrid signer key id, e.g. ed25519:abc123...') - ap.add_argument('--trusted-public-key',default=None,help='Pin signer identity to an out-of-band trusted PEM public key') - args=ap.parse_args();r=verify_bundle(args.bundle,args.tsa_ca_file,args.expected_key_id,args.trusted_public_key) - # Keep CLI status text ASCII-only. On Windows, a verifier launched with stdout - # redirected to a subprocess pipe may inherit a legacy code page that cannot - # encode Unicode check/cross glyphs even though verification itself succeeded. - print('LOOPGRID EVIDENCE VERIFICATION') - print('[OK] VERIFIED' if r['valid'] else '[FAIL] INVALID') - print(json.dumps(r,indent=2)) - raise SystemExit(0 if r['valid'] else 2) - -if __name__=='__main__':main()