#!/usr/bin/env python3 """Verify a KRINEIA JSONL receipt hash chain. The verifier's structural and hash-chain checks are stdlib-only. Ed25519 verification uses the optional ``cryptography`` package; unlike legacy v2.0, v2.1 fails closed when that package or a verification key is unavailable. It validates three receipt envelopes: - each line is a JSON object - required top-level fields are present - the genesis receipt has prev_hash of 64 zeroes - each receipt hash matches canonical JSON without the hash field - each receipt prev_hash links to the prior receipt hash - for legacy v2.0.0 receipts, the additional known fields (``schema_version``, ``session_id``, ``actor_identity``, ``signature_envelope``) are accepted as optional - v2.1.0 receipts require a valid Ed25519 signature and every schema field Schema-version dispatch: - v0.1 receipts (no ``schema_version`` field) use the original six-field canonical form and the original required-field set, but require explicit legacy-structural policy and are reported as unauthenticated warnings. - v2.0.0 receipts retain their historical algorithm: hash the body excluding ``signature_envelope`` and ``hash``, then optionally sign that hash. - v2.1.0 receipts sign the canonical body (including signature metadata but excluding ``signature`` and ``hash``), then hash the complete signed envelope excluding only ``hash``. - a chain may move from an explicitly admitted legacy prefix to v2.1, but may never downgrade after its first v2.1 receipt. - optional trusted expected-head and expected-count inputs detect clean tail removal, which adjacent hash links alone cannot detect. Tamper-evidence policy (P0-1): a hash mismatch is *always* an error and *always* produces a non-zero exit code, even when a ``governance.corrective_notice`` receipt documents the broken id. A corrective notice adds context to the report but does NOT suppress the error or change the exit code -- the chain was tampered and the verifier must say so. """ from __future__ import annotations import argparse import hashlib import hmac import json import math import os import re import sys import uuid from datetime import datetime from pathlib import Path from typing import Any V01_FIELDS = ("id", "time", "state", "drift", "prev_hash", "hash") # Additional fields recognised by the v2.0.0 envelope. They are optional # (their presence does not affect hash-chain linkage) but they are NOT # "unknown" fields for v2.0.0 receipts, so they must not be rejected. V2_OPTIONAL_FIELDS = ( "schema_version", "session_id", "actor_identity", "signature_envelope", ) V21_FIELDS = ( "schema_version", "id", "session_id", "actor_identity", "time", "state", "drift", "prev_hash", "signature_envelope", "hash", ) V21_SIGNATURE_FIELDS = ("key_id", "signer_identity", "algorithm", "signature") V21_SIGNATURE_METADATA_FIELDS = ("key_id", "signer_identity", "algorithm") HASH_ZERO = "0" * 64 V20 = "2.0.0" V21 = "2.1.0" DEFAULT_MAX_CHAIN_BYTES = 512 * 1024 * 1024 DEFAULT_MAX_RECEIPTS = 1_000_000 MAX_CHAIN_BYTES = DEFAULT_MAX_CHAIN_BYTES MAX_LINE_BYTES = 1024 * 1024 MAX_RECEIPTS = DEFAULT_MAX_RECEIPTS MAX_JSON_DEPTH = 64 MAX_JSON_NODES = 10_000 MAX_KEY_REGISTRY_BYTES = 1024 * 1024 _IDENTITY_RE = re.compile(r"^[A-Za-z0-9_.-]+$") _KEY_ID_RE = re.compile(r"^[A-Za-z0-9_./#-]+$") _TIME_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$") class DuplicateKeyError(ValueError): """Raised when a JSON object contains a duplicate member name.""" def _reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: result: dict[str, Any] = {} for key, value in pairs: if key in result: raise DuplicateKeyError(f"duplicate object member {key!r}") result[key] = value return result def _reject_nonfinite_constant(value: str) -> Any: raise ValueError(f"non-finite JSON number {value!r}") def _safe_display(value: object, limit: int = 256) -> str: """Render untrusted text as one bounded, escaped diagnostic token.""" rendered = json.dumps(str(value), ensure_ascii=True) if len(rendered) > limit: return rendered[: limit - 4] + "...\"" return rendered def _json_within_limits(value: Any) -> tuple[bool, str | None]: """Bound decoded JSON depth and node count without recursive traversal.""" stack: list[tuple[Any, int]] = [(value, 1)] nodes = 0 while stack: current, depth = stack.pop() nodes += 1 if nodes > MAX_JSON_NODES: return False, f"JSON exceeds {MAX_JSON_NODES} nodes" if depth > MAX_JSON_DEPTH: return False, f"JSON exceeds maximum depth {MAX_JSON_DEPTH}" if isinstance(current, dict): stack.extend((item, depth + 1) for item in current.values()) elif isinstance(current, list): stack.extend((item, depth + 1) for item in current) return True, None def parse_key_registry(raw: str, *, source: str = "key registry") -> dict[str, Any]: """Parse a bounded, duplicate-free public verification-key registry.""" if len(raw.encode("utf-8")) > MAX_KEY_REGISTRY_BYTES: raise ValueError(f"{source} exceeds {MAX_KEY_REGISTRY_BYTES} bytes") try: parsed = json.loads( raw, object_pairs_hook=_reject_duplicate_keys, parse_constant=_reject_nonfinite_constant, ) except (json.JSONDecodeError, DuplicateKeyError, ValueError, RecursionError) as exc: raise ValueError(f"invalid {source}: {exc}") from exc within_limits, limit_error = _json_within_limits(parsed) if not within_limits: raise ValueError(f"invalid {source}: {limit_error}") if not isinstance(parsed, dict): raise ValueError(f"invalid {source}: top level must be a JSON object") for key_id in parsed: if not isinstance(key_id, str) or _KEY_ID_RE.fullmatch(key_id) is None: raise ValueError( f"invalid {source}: key_id {_safe_display(key_id)} is not canonical" ) return parsed def _is_v20(receipt: dict[str, Any]) -> bool: return receipt.get("schema_version") == V20 def _is_v21(receipt: dict[str, Any]) -> bool: return receipt.get("schema_version") == V21 def _canonical_json_v21(value: Any) -> str: """Return the normative KRINEIA v2.1 canonical JSON serialization. Member names are recursively sorted, insignificant whitespace is omitted, non-ASCII Unicode is emitted directly, and non-finite numbers are rejected. The UTF-8 bytes of this string are the cryptographic input. """ return json.dumps( value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False, ) def canonical_receipt(receipt: dict[str, Any]) -> str: """Return the canonical form hashed by KRINEIA receipts. For v0.1 receipts (no ``schema_version``) the canonical form is the original six fields (``id``, ``time``, ``state``, ``drift``, ``prev_hash``). Legacy v2.0.0 excludes ``signature_envelope`` and ``hash``. v2.1.0 excludes only ``hash`` and therefore commits to the signature as well as its metadata. """ if _is_v21(receipt): canonical = {k: v for k, v in receipt.items() if k != "hash"} return _canonical_json_v21(canonical) if _is_v20(receipt): canonical = { k: v for k, v in receipt.items() if k not in ("signature_envelope", "hash") } elif "schema_version" not in receipt: canonical = { "id": receipt["id"], "time": receipt["time"], "state": receipt["state"], "drift": receipt["drift"], "prev_hash": receipt["prev_hash"], } else: raise ValueError(f"unsupported schema_version {receipt.get('schema_version')!r}") return json.dumps(canonical, sort_keys=True, separators=(",", ":")) def canonical_signed_body(receipt: dict[str, Any]) -> str: """Return the exact v2.1 body authenticated by Ed25519. The signed body contains every top-level field except ``hash``. Within ``signature_envelope`` it contains the three signature metadata fields and excludes only ``signature``. This breaks the otherwise circular dependency while binding the algorithm, key identifier, and signer identity. """ if not _is_v21(receipt): raise ValueError("canonical_signed_body is defined only for schema 2.1.0") envelope = receipt.get("signature_envelope") if not isinstance(envelope, dict): raise ValueError("signature_envelope must be an object") body = {k: v for k, v in receipt.items() if k != "hash"} body["signature_envelope"] = { field: envelope[field] for field in V21_SIGNATURE_METADATA_FIELDS } return _canonical_json_v21(body) def sha256_hex(text: str) -> str: return hashlib.sha256(text.encode("utf-8")).hexdigest() def is_hash(value: object) -> bool: if not isinstance(value, str) or len(value) != 64: return False return all(char in "0123456789abcdef" for char in value) def _valid_uuid4(value: object) -> bool: if not isinstance(value, str): return False try: parsed = uuid.UUID(value) except (ValueError, AttributeError): return False return parsed.version == 4 and str(parsed) == value def _validate_v21_shape(receipt: dict[str, Any], line_number: int) -> list[str]: prefix = f"line {line_number}:" errors: list[str] = [] if not _valid_uuid4(receipt.get("id")): errors.append(f"{prefix} id must be a canonical lowercase UUIDv4") session_id = receipt.get("session_id") if not isinstance(session_id, str) or not 1 <= len(session_id) <= 256: errors.append(f"{prefix} session_id must be a non-empty string of at most 256 characters") actor = receipt.get("actor_identity") if ( not isinstance(actor, str) or not 1 <= len(actor) <= 128 or _IDENTITY_RE.fullmatch(actor) is None ): errors.append(f"{prefix} actor_identity is not a canonical bare identity") timestamp = receipt.get("time") if not isinstance(timestamp, str) or _TIME_RE.fullmatch(timestamp) is None: errors.append(f"{prefix} time must be ISO 8601 UTC with a Z suffix") else: try: datetime.fromisoformat(timestamp[:-1] + "+00:00") except ValueError: errors.append(f"{prefix} time is not a valid UTC timestamp") state = receipt.get("state") if not isinstance(state, dict): errors.append(f"{prefix} state must be an object") else: state_extra = sorted(set(state) - {"event", "payload"}) state_missing = sorted({"event", "payload"} - set(state)) if state_missing: errors.append( f"{prefix} state missing required fields: " + ", ".join(_safe_display(field) for field in state_missing) ) if state_extra: errors.append( f"{prefix} state has unknown fields: " + ", ".join(_safe_display(field) for field in state_extra) ) event = state.get("event") if ( not isinstance(event, str) or not 1 <= len(event) <= 128 or _IDENTITY_RE.fullmatch(event) is None ): errors.append(f"{prefix} state.event is not a canonical event identity") if not isinstance(state.get("payload"), dict): errors.append(f"{prefix} state.payload must be an object") drift = receipt.get("drift") if drift is not None: if isinstance(drift, bool) or not isinstance(drift, (int, float)): errors.append(f"{prefix} drift must be a number or null") elif ( (isinstance(drift, float) and not math.isfinite(drift)) or not 0.0 <= drift <= 1.0 ): errors.append(f"{prefix} drift must be finite and between 0 and 1") envelope = receipt.get("signature_envelope") if not isinstance(envelope, dict): errors.append(f"{prefix} signature_envelope must be an object") else: missing = sorted(set(V21_SIGNATURE_FIELDS) - set(envelope)) extra = sorted(set(envelope) - set(V21_SIGNATURE_FIELDS)) if missing: errors.append( f"{prefix} signature_envelope missing required fields: " + ", ".join(_safe_display(field) for field in missing) ) if extra: errors.append( f"{prefix} signature_envelope has unknown fields: " + ", ".join(_safe_display(field) for field in extra) ) key_id = envelope.get("key_id") if ( not isinstance(key_id, str) or not 1 <= len(key_id) <= 256 or _KEY_ID_RE.fullmatch(key_id) is None ): errors.append(f"{prefix} signature_envelope.key_id is invalid") signer = envelope.get("signer_identity") if ( not isinstance(signer, str) or not 1 <= len(signer) <= 128 or _IDENTITY_RE.fullmatch(signer) is None ): errors.append(f"{prefix} signature_envelope.signer_identity is invalid") elif signer == actor: errors.append(f"{prefix} signer_identity must differ from actor_identity") if envelope.get("algorithm") != "Ed25519": errors.append(f"{prefix} schema 2.1.0 requires algorithm Ed25519") signature = envelope.get("signature") if ( not isinstance(signature, str) or len(signature) != 128 or any(char not in "0123456789abcdef" for char in signature) ): errors.append(f"{prefix} Ed25519 signature must be 128 lowercase hex characters") try: _canonical_json_v21({k: v for k, v in receipt.items() if k != "hash"}).encode("utf-8") except (TypeError, ValueError, UnicodeEncodeError, RecursionError) as exc: errors.append(f"{prefix} receipt is not v2.1-canonicalizable: {exc}") return errors def validate_receipt(receipt: object, line_number: int, previous_hash: str | None) -> list[str]: errors: list[str] = [] if not isinstance(receipt, dict): return [f"line {line_number}: receipt is not a JSON object"] version = receipt.get("schema_version") if version is None: required = V01_FIELDS known = set(V01_FIELDS) elif version == V20: required = V01_FIELDS known = set(V01_FIELDS) | set(V2_OPTIONAL_FIELDS) elif version == V21: required = V21_FIELDS known = set(V21_FIELDS) else: return [f"line {line_number}: unsupported schema_version {version!r}"] missing = [field for field in required if field not in receipt] if missing: errors.append( f"line {line_number}: missing required fields: " + ", ".join(_safe_display(field) for field in missing) ) return errors extra = sorted(set(receipt) - known) if extra: errors.append( f"line {line_number}: unknown top-level fields: " + ", ".join(_safe_display(field) for field in extra) ) for field in ("prev_hash", "hash"): if not is_hash(receipt[field]): errors.append(f"line {line_number}: {field} must be 64 lowercase hex characters") if previous_hash is None: if receipt["prev_hash"] != HASH_ZERO: errors.append(f"line {line_number}: genesis prev_hash must be {HASH_ZERO}") elif receipt["prev_hash"] != previous_hash: errors.append( f"line {line_number}: prev_hash {_safe_display(receipt['prev_hash'])} " f"does not match prior hash {_safe_display(previous_hash)}" ) if _is_v21(receipt): errors.extend(_validate_v21_shape(receipt, line_number)) try: expected_hash = sha256_hex(canonical_receipt(receipt)) except (KeyError, TypeError, ValueError, UnicodeEncodeError, RecursionError) as exc: errors.append(f"line {line_number}: cannot canonicalize receipt: {exc}") else: if receipt["hash"] != expected_hash: errors.append(f"line {line_number}: hash mismatch, expected {expected_hash}") return errors # --------------------------------------------------------------------------- # Signature verification (P1-8) # --------------------------------------------------------------------------- def _load_keys(keys_path: Path | None) -> dict[str, Any]: """Load signature-verification keys. Keys are merged from, in increasing precedence: 1. a ``--keys`` JSON file mapping ``key_id`` -> key material 2. the ``KRINEIA_SIGNING_KEYS`` env var (a JSON object of the same shape) 3. the ``BUS_SIGNING_SECRET`` / ``KRINEIA_SIGNING_SECRET`` env var, mapped to key_id ``"v1"`` (the historical v2.0 HMAC key id) For legacy v2.0 HMAC-SHA256 the key material is the shared secret string. For Ed25519 the key material is the 32-byte, hex-encoded public key (for v2.1, an object ``{"algorithm": "Ed25519", "public_key": "...", "signer_identity": "..."}``). Strict v2.1 verification requires the matching public key and an exact registry binding between ``key_id`` and ``signer_identity``. """ keys: dict[str, Any] = {} if keys_path is not None: try: if keys_path.stat().st_size > MAX_KEY_REGISTRY_BYTES: raise ValueError( f"key registry exceeds {MAX_KEY_REGISTRY_BYTES} bytes" ) raw = parse_key_registry( keys_path.read_text(encoding="utf-8"), source=f"keys file {keys_path}", ) except (OSError, UnicodeError, ValueError) as exc: raise SystemExit(f"could not load keys file {keys_path}: {exc}") keys.update(raw) env_json = os.environ.get("KRINEIA_SIGNING_KEYS") # Multi-key public registry for v2.1 verification and key rotation. if env_json: try: parsed = parse_key_registry( env_json, source="KRINEIA_SIGNING_KEYS" ) except (UnicodeError, ValueError) as exc: raise SystemExit(str(exc)) keys.update(parsed) secret = os.environ.get("BUS_SIGNING_SECRET") or os.environ.get("KRINEIA_SIGNING_SECRET") if secret and "v1" not in keys: keys["v1"] = secret return keys def verify_signature( receipt: dict[str, Any], keys: dict[str, Any], line_number: int ) -> tuple[list[str], list[str]]: """Verify a receipt's ``signature_envelope`` when present. Returns (errors, warnings). - No / empty envelope (v0.1 receipts or unsigned legacy v2.0.0 receipts): no check is performed; returns empty lists. - Envelope present but malformed (missing ``algorithm``, ``key_id`` or ``signature``): error. - HMAC-SHA256: verified against the key looked up by ``key_id``. If no key is available, a warning is emitted (signature not verified) but verification does not fail. - Ed25519 on v2.0 verifies the legacy signed hash. Missing dependencies or keys remain warnings for historical compatibility. - Ed25519 on v2.1 verifies the canonical signed body and fails closed for malformed envelopes, missing dependencies, missing keys, or bad keys. """ errors: list[str] = [] warnings: list[str] = [] strict_v21 = _is_v21(receipt) envelope = receipt.get("signature_envelope") if not isinstance(envelope, dict) or not envelope: if strict_v21: errors.append(f"line {line_number}: schema 2.1.0 requires a signature_envelope") return errors, warnings algorithm = envelope.get("algorithm") key_id = envelope.get("key_id") signature = envelope.get("signature") if not algorithm or not key_id or not signature: errors.append( f"line {line_number}: malformed signature_envelope " "(requires algorithm, key_id, signature)" ) return errors, warnings if strict_v21: try: signed_payload = canonical_signed_body(receipt) except (KeyError, TypeError, ValueError, UnicodeEncodeError, RecursionError) as exc: errors.append(f"line {line_number}: cannot canonicalize signed body: {exc}") return errors, warnings else: # Historical v2.0 signs the previously computed receipt hash. signed_payload = str(receipt.get("hash", "")) alg = str(algorithm).lower().replace("_", "-") key_id_str = str(key_id) if alg == "hmac-sha256": if strict_v21: errors.append(f"line {line_number}: schema 2.1.0 forbids HMAC-SHA256") return errors, warnings key = keys.get(key_id_str) if key is None: warnings.append( f"line {line_number}: HMAC-SHA256 signature present but no key " f"for key_id {_safe_display(key_id_str)} (signature not verified)" ) return errors, warnings if not isinstance(key, str): key = str(key) expected = hmac.new( key.encode("utf-8"), signed_payload.encode("utf-8"), hashlib.sha256, ).hexdigest() if not hmac.compare_digest(str(signature), expected): errors.append( f"line {line_number}: HMAC-SHA256 signature mismatch for key_id " f"{_safe_display(key_id_str)}" ) elif alg == "ed25519": # v2.1 uses public Ed25519 verification so verifiers never hold the # signing key. The same branch reads historical v2.0 Ed25519 receipts. try: from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey from cryptography.exceptions import InvalidSignature except ImportError: finding = ( f"line {line_number}: Ed25519 signature present but the " "'cryptography' package is not installed (signature not verified)" ) (errors if strict_v21 else warnings).append(finding) return errors, warnings key = keys.get(key_id_str) if key is None: finding = ( f"line {line_number}: Ed25519 signature present but no key " f"for key_id {_safe_display(key_id_str)} (signature not verified)" ) (errors if strict_v21 else warnings).append(finding) return errors, warnings if strict_v21 and not isinstance(key, dict): errors.append( f"line {line_number}: v2.1 key {_safe_display(key_id_str)} must be a registry " "object binding public_key and signer_identity" ) return errors, warnings if isinstance(key, dict): declared_algorithm = key.get("algorithm") if strict_v21 and declared_algorithm != "Ed25519": errors.append( f"line {line_number}: v2.1 key {_safe_display(key_id_str)} must declare " "algorithm 'Ed25519'" ) return errors, warnings if declared_algorithm is not None and str(declared_algorithm).lower() != "ed25519": errors.append( f"line {line_number}: key {_safe_display(key_id_str)} is not declared for Ed25519" ) return errors, warnings if strict_v21: registered_signer = key.get("signer_identity") claimed_signer = envelope.get("signer_identity") if registered_signer != claimed_signer: errors.append( f"line {line_number}: key {_safe_display(key_id_str)} is not registered " f"for signer_identity {_safe_display(claimed_signer)}" ) return errors, warnings if set(key) != { "algorithm", "public_key", "signer_identity", }: errors.append( f"line {line_number}: v2.1 key {_safe_display(key_id_str)} " "must contain exactly algorithm, public_key, and signer_identity" ) return errors, warnings key = key.get("public_key") try: pub = Ed25519PublicKey.from_public_bytes(bytes.fromhex(str(key))) pub.verify( bytes.fromhex(str(signature)), signed_payload.encode("utf-8"), ) except (TypeError, ValueError, InvalidSignature): errors.append( f"line {line_number}: Ed25519 signature verification failed for key_id " f"{_safe_display(key_id_str)}" ) else: errors.append( f"line {line_number}: unknown signature algorithm {_safe_display(algorithm)}" ) return errors, warnings def verify_chain( path: Path, keys: dict[str, Any] | None = None, *, allow_legacy_structural: bool = False, expected_head: str | None = None, expected_count: int | None = None, max_receipts: int | None = None, max_chain_bytes: int | None = None, ) -> tuple[int, list[str], list[str]]: """Verify a chain. Returns ``(receipt_count, errors, warnings)``. Warnings are advisories (e.g. explicitly requested unsigned v0.1 structural inspection or a legacy signature that could not be verified). Errors are fatal: any error -- including a documented hash mismatch -- causes the verifier to exit non-zero (see P0-1). The CLI also treats warnings as non-success unless the caller explicitly selects its warning-tolerant archival policy. The return type is a 3-tuple ``(int, list[str], list[str])``; the daemon deliberately delegates to this function so writer and CLI validation cannot drift. """ if keys is None: keys = {} errors: list[str] = [] warnings: list[str] = [] if (expected_head is None) != (expected_count is None): return 0, ["expected_head and expected_count must be supplied together"], [] if expected_head is not None and not is_hash(expected_head): return 0, ["expected_head must be 64 lowercase hex characters"], [] if ( expected_count is not None and (isinstance(expected_count, bool) or not isinstance(expected_count, int) or expected_count < 0) ): return 0, ["expected_count must be a non-negative integer"], [] effective_max_receipts = MAX_RECEIPTS if max_receipts is None else max_receipts effective_max_chain_bytes = MAX_CHAIN_BYTES if max_chain_bytes is None else max_chain_bytes previous_hash: str | None = None count = 0 total_bytes = 0 line_number = 0 corrective_notice_present = False seen_v21 = False try: chain_handle = path.open("rb") except OSError as exc: return 0, [f"cannot open chain {path}: {exc}"], [] with chain_handle as handle: while True: raw_line = handle.readline(MAX_LINE_BYTES + 1) if not raw_line: break line_number += 1 total_bytes += len(raw_line) if effective_max_chain_bytes > 0 and total_bytes > effective_max_chain_bytes: errors.append( f"line {line_number}: chain exceeds {effective_max_chain_bytes} bytes" ) break if len(raw_line) > MAX_LINE_BYTES: while raw_line and not raw_line.endswith(b"\n"): raw_line = handle.readline(MAX_LINE_BYTES + 1) total_bytes += len(raw_line) if effective_max_chain_bytes > 0 and total_bytes > effective_max_chain_bytes: break errors.append( f"line {line_number}: receipt line exceeds {MAX_LINE_BYTES} bytes" ) previous_hash = None if effective_max_chain_bytes > 0 and total_bytes > effective_max_chain_bytes: errors.append(f"chain exceeds {effective_max_chain_bytes} bytes") break continue try: line = raw_line.decode("utf-8", errors="strict") except UnicodeDecodeError as exc: errors.append(f"line {line_number}: invalid UTF-8: {exc}") previous_hash = None continue if not line.strip(): continue if effective_max_receipts > 0 and count >= effective_max_receipts: errors.append(f"chain exceeds {effective_max_receipts} receipts") break try: receipt = json.loads( line, object_pairs_hook=_reject_duplicate_keys, parse_constant=_reject_nonfinite_constant, ) except ( json.JSONDecodeError, DuplicateKeyError, ValueError, RecursionError, ) as exc: errors.append(f"line {line_number}: invalid JSON: {exc}") previous_hash = None continue within_limits, limit_error = _json_within_limits(receipt) if not within_limits: errors.append(f"line {line_number}: {limit_error}") previous_hash = None continue # Only count successfully parsed, bounded receipt values. count += 1 if isinstance(receipt, dict): version = receipt.get("schema_version") if version is None: if seen_v21: errors.append( f"line {line_number}: schema downgrade from 2.1.0 to unsigned v0.1 is forbidden" ) if allow_legacy_structural: warnings.append( f"line {line_number}: unsigned v0.1 receipt was checked structurally only" ) else: errors.append( f"line {line_number}: unsigned v0.1 requires explicit legacy structural policy" ) elif version == V20 and seen_v21: errors.append( f"line {line_number}: schema downgrade from 2.1.0 to deprecated 2.0.0 is forbidden" ) elif version == V21: seen_v21 = True state = receipt.get("state") if ( isinstance(state, dict) and state.get("event") == "governance.corrective_notice" ): corrective_notice_present = True receipt_errors = validate_receipt(receipt, line_number, previous_hash) errors.extend(receipt_errors) if isinstance(receipt, dict) and _is_v20(receipt): warnings.append( f"line {line_number}: schema 2.0.0 is deprecated and verification-only; " "new receipts must use schema 2.1.0" ) # Verify legacy signatures when present and mandatory v2.1 # signatures on every receipt. if isinstance(receipt, dict) and (_is_v20(receipt) or _is_v21(receipt)): sig_errors, sig_warnings = verify_signature(receipt, keys, line_number) errors.extend(sig_errors) warnings.extend(sig_warnings) if isinstance(receipt, dict) and is_hash(receipt.get("hash")): previous_hash = receipt["hash"] else: previous_hash = None if count == 0: errors.append("chain is empty") if expected_count is not None and count != expected_count: errors.append( f"chain receipt count {count} does not match expected count {expected_count}" ) if expected_head is not None and previous_hash != expected_head: actual = previous_hash if previous_hash is not None else "unavailable" errors.append( f"chain head {actual} does not match expected head {expected_head}" ) # P0-1: corrective-notice context. A documented hash mismatch is STILL # an error (the chain was tampered -> non-zero exit). When a # governance.corrective_notice receipt is present, we annotate every # hash-mismatch error with audit context in a single pass. No severity # change, no exit-code change. if corrective_notice_present: for j, e in enumerate(errors): if e.startswith("line ") and "hash mismatch" in e: errors[j] = ( e + " (documented by governance.corrective_notice -- " "chain still tampered, exit non-zero)" ) return count, errors, warnings def main() -> int: parser = argparse.ArgumentParser(description="Verify a KRINEIA JSONL receipt hash chain") parser.add_argument("chain", type=Path, help="Path to a KRINEIA .jsonl receipt chain") # bespoke: CI escape hatch for pipelines that want warnings to be non-fatal; # hash mismatches are always errors regardless of this flag. parser.add_argument("--warn-only", action="store_true", help="Exit 0 if only warnings remain (no errors). " "Has no effect on hash mismatches, which are always errors.") parser.add_argument( "--allow-legacy-structural", action="store_true", help="Inspect unsigned v0.1 receipts structurally. Each remains a warning; " "use a trusted expected head/count and an explicit warning policy for archival gates.", ) parser.add_argument( "--expected-head", default=None, help="Trusted expected terminal receipt hash (64 lowercase hex characters).", ) parser.add_argument( "--expected-count", type=int, default=None, help="Trusted expected number of parsed receipts.", ) parser.add_argument( "--allow-unanchored", action="store_true", help="Explicitly permit internal hash/link verification without an external " "expected head and count. This cannot establish tail completeness.", ) parser.add_argument( "--keys", type=Path, default=None, help="Path to a JSON file mapping key_id -> verification-key material. " "KRINEIA_SIGNING_KEYS is also honoured; BUS_SIGNING_SECRET and " "KRINEIA_SIGNING_SECRET are legacy v2.0 HMAC fallbacks only.", ) parser.add_argument( "--max-receipts", type=int, default=None, help="Maximum receipts to verify (default: 1,000,000; pass 0 for unlimited).", ) parser.add_argument( "--max-bytes", type=int, default=None, help="Maximum chain size in bytes (default: 536,870,912; pass 0 for unlimited).", ) args = parser.parse_args() keys = _load_keys(args.keys) count, errors, warnings = verify_chain( args.chain, keys, allow_legacy_structural=args.allow_legacy_structural, expected_head=args.expected_head, expected_count=args.expected_count, max_receipts=args.max_receipts, max_chain_bytes=args.max_bytes, ) unanchored = args.expected_head is None or args.expected_count is None if unanchored: anchor_message = ( "chain completeness was not checked: both --expected-head and " "--expected-count are required for an anchored result" ) if args.allow_unanchored: warnings.append(anchor_message + " (explicit unanchored inspection policy)") else: errors.append( anchor_message + "; pass --allow-unanchored only for non-assurance inspection" ) for warning in warnings: print(f"WARN {warning}", file=sys.stderr) if errors: for error in errors: print(f"FAIL {error}", file=sys.stderr) print(f"KRINEIA chain invalid: {count} receipt(s), {len(errors)} error(s), {len(warnings)} warning(s)", file=sys.stderr) return 1 if warnings: print( f"KRINEIA chain internally valid with warnings: {count} receipt(s), " f"{len(warnings)} warning(s)" ) # P1-6: --warn-only makes warnings non-fatal; without it warnings # cause a non-zero exit so CI fails on advisory findings. Hash # mismatches are never warnings (they are errors, handled above), so # this branch never suppresses tamper-evidence. return 0 if args.warn_only else 1 print(f"KRINEIA chain anchored and valid: {count} receipt(s)") return 0 if __name__ == "__main__": sys.exit(main())