From 24c94eb97114dfd52bbf5e67daf7e47616474d9f Mon Sep 17 00:00:00 2001 From: lywinged Date: Tue, 18 Aug 2026 17:50:42 +1200 Subject: [PATCH] fix(verify): a link naming an unimplemented digest is unverifiable, not tampering verify_trace_dag accepted sha384: as a well-formed parent link and then compared it against a value trace_record_hash only ever computes as sha256:. The mismatch was reported as ProvenanceLinkBroken, whose documented meaning in failure-modes.md is that tampering or reparenting was detected. Nothing was tampered with. The two halves disagreed: _DIGEST_RE admits both digests the TRACE schema permits for delegation.parent_record_hash, and the comparison had not caught up. A conforming, correctly signed chain differing from an accepted baseline only in which permitted digest names its parent was reported as a tampering finding, and an auditor reading the outcome had no way to tell that from a real one. Fail-closed either way: no forged, reparented or tampered DAG was accepted as a result, and every sha256 chain verifies exactly as before. What changes is what the verifier says about a chain it cannot read. TraceDigestUnsupported (TRACE_DIGEST_UNSUPPORTED, 501) is raised instead, before the comparison and per hop, so an unreadable link deep in a chain is not masked by a readable one at the leaf. LINK_DIGEST names the algorithm once and is used both to label and to compute the hash, so the verifier cannot drift from the producer. Tests cover the leaf case, the deep case, and a control that a well-formed link pointing at the wrong record still raises ProvenanceLinkBroken: the fix narrows tamper detection, it does not replace it. Both new tests fail without the guard. Portable vectors for this case are agentrust-io/trace-spec#184, numbers 22 and 23, which assert unverifiable with digest_algorithm_unsupported. Signed-off-by: lywinged Co-Authored-By: Claude Opus 5 (1M context) --- docs/spec/error-codes.md | 3 +- docs/spec/failure-modes.md | 2 +- src/ca2a_runtime/errors.py | 19 +++++ src/ca2a_runtime/trace_binding.py | 9 ++- src/ca2a_verify/dag.py | 26 ++++-- tests/unit/test_trace_binding.py | 128 +++++++++++++++++++++++++++++- 6 files changed, 178 insertions(+), 9 deletions(-) diff --git a/docs/spec/error-codes.md b/docs/spec/error-codes.md index 58688de..133934e 100644 --- a/docs/spec/error-codes.md +++ b/docs/spec/error-codes.md @@ -24,11 +24,12 @@ An error also carries a human-readable message and an optional `detail`. The mes | `SealedChannelError` | `SEALED_CHANNEL_ERROR` | 500 | The sealed peer channel could not construct or open a payload: an invalid peer public key, a malformed or unsupported sealed blob, a wrong key, or a tampered ciphertext (AEAD authentication failure). Fails closed; never returns unauthenticated plaintext. See [Sealed Channel](sealed-channel.md). | | `ProvenanceLinkBroken` | `PROVENANCE_LINK_BROKEN` | 409 | A `DelegationRecord` does not chain to its stated parent record, or a record was tampered with so its hash no longer matches a child's link: empty provenance chain, duplicate `record_id`, a root record that references a parent, a broken parent hash link, or a record whose `credential_id` or subject does not match the chain. Raised by `verify_dag` and `cross_check_chain`. | | `ScopeNotPermitted` | `SCOPE_NOT_PERMITTED` | 403 | A requested capability is not in the effective scope (the delegated leaf scope intersected with the callee's local policy). Raised by `enforce_peer_call`. | +| `TraceDigestUnsupported` | `TRACE_DIGEST_UNSUPPORTED` | 501 | A `delegation.parent_record_hash` names a digest algorithm this verifier does not compute. The TRACE schema permits `sha256:` and `sha384:`; `trace_record_hash` computes `LINK_DIGEST`. Such a link is well formed and may be correct, so the chain is **unverifiable** at that hop rather than invalid, and is reported separately from `PROVENANCE_LINK_BROKEN` so an audit record does not carry a tampering finding that no tampering produced. Raised by `verify_trace_dag`, per hop. | | `TransportError` | `TRANSPORT_ERROR` | 400 | cA2A A2A-extension metadata was present but malformed or incomplete (missing `delegation_chain`, bad hop shape, non-base64url `sealed_payload`, etc.). Raised by `ca2a_runtime.transport.parse_peer_request`. Absence of all cA2A keys is not an error: that message is ordinary A2A input. | ## Which errors are live today -`ConfigError`, `InvalidCredential`, `ScopeEscalation`, `BrokenDelegationLink`, `DelegationDepthExceeded`, `CredentialReplay`, `CredentialNotYetValid`, `CredentialExpired`, and `ProvenanceLinkBroken` are raised by shipping code paths: attenuated delegation, offline chain verification, and the provenance DAG. `ScopeNotPermitted` is raised by the peer-call enforcement decision core (`enforce_peer_call`), and `SealedChannelError` by the sealed channel (`SealedChannel.seal`, `open_sealed`), both of which are implemented. `TransportError` is raised by the A2A metadata adapter when cA2A keys are present but cannot be parsed into a `PeerRequest`. +`ConfigError`, `InvalidCredential`, `ScopeEscalation`, `BrokenDelegationLink`, `DelegationDepthExceeded`, `CredentialReplay`, `CredentialNotYetValid`, `CredentialExpired`, , `ProvenanceLinkBroken`, and `TraceDigestUnsupported` are raised by shipping code paths: attenuated delegation, offline chain verification, and the provenance DAG. `ScopeNotPermitted` is raised by the peer-call enforcement decision core (`enforce_peer_call`), and `SealedChannelError` by the sealed channel (`SealedChannel.seal`, `open_sealed`), both of which are implemented. `TransportError` is raised by the A2A metadata adapter when cA2A keys are present but cannot be parsed into a `PeerRequest`. `AttestationFailed` is raised by the SEV-SNP verifier (chain, report signature, and measurement binding), and by a collector whose hardware returned evidence that does not commit the key and nonce it asked for. `AttestationUnsupported` is raised where a host cannot collect at all: no TPM or tpm2-pytss for `tpm`, no configfs-TSM or guest device for `sev-snp` and `tdx`, and on Azure confidential VMs, where SEV-SNP runs behind a paravisor that owns `REPORT_DATA`. See [Peer Attestation](attestation.md) and [ROADMAP.md](../../ROADMAP.md). diff --git a/docs/spec/failure-modes.md b/docs/spec/failure-modes.md index 81d1d56..ed35664 100644 --- a/docs/spec/failure-modes.md +++ b/docs/spec/failure-modes.md @@ -92,7 +92,7 @@ except ProvenanceLinkBroken as exc: - the record list is empty; - the first record carries a parent link (a root must not reference a parent); -- any later record's `parent_record_hash` does not equal the recomputed hash of the immediately preceding record (this catches both tampering and reparenting); +- any later record's `parent_record_hash` does not equal the recomputed hash of the immediately preceding record (this catches both tampering and reparenting). A link naming a digest this verifier does not compute never reaches that comparison: it raises `TRACE_DIGEST_UNSUPPORTED` instead, because a hash that was never recomputed cannot be evidence of tampering; - a `record_id` repeats. `cross_check_chain` ties provenance back to authority: record `i` must reference credential `i` and carry the same `subject`. A mismatch in length, `credential_id`, or `subject` raises `PROVENANCE_LINK_BROKEN`. A verified DAG that does not line up with the delegation chain it claims to describe is not accepted as evidence. diff --git a/src/ca2a_runtime/errors.py b/src/ca2a_runtime/errors.py index aba1d75..0801682 100644 --- a/src/ca2a_runtime/errors.py +++ b/src/ca2a_runtime/errors.py @@ -134,6 +134,25 @@ class ProvenanceLinkBroken(CA2AError): http_status = 409 +class TraceDigestUnsupported(CA2AError): + """A parent link names a digest algorithm this verifier does not implement. + + The TRACE schema permits ``sha256:`` and ``sha384:`` for + ``delegation.parent_record_hash``; this verifier computes only + :data:`~ca2a_runtime.trace_binding.LINK_DIGEST`. A link naming the other one + is well formed and may be entirely correct, so the chain is *unverifiable* + here rather than invalid. + + Distinct from :class:`ProvenanceLinkBroken` on purpose. That code means a + record contradicts its parent; this one means nothing was compared. Reporting + an unimplemented digest as a broken link puts a tampering finding into an + audit record that no tampering produced. + """ + + code = "TRACE_DIGEST_UNSUPPORTED" + http_status = 501 + + class ScopeNotPermitted(CA2AError): """A requested capability is not in the effective scope (the delegated scope intersected with the callee's local policy). diff --git a/src/ca2a_runtime/trace_binding.py b/src/ca2a_runtime/trace_binding.py index cb6800a..802454a 100644 --- a/src/ca2a_runtime/trace_binding.py +++ b/src/ca2a_runtime/trace_binding.py @@ -197,6 +197,13 @@ def sign_trace_record(record: dict[str, Any], key: Ed25519PrivateKey) -> dict[st return sign_record(record, key) +#: The digest a parent link commits to. Named once and used both to label and +#: to compute the hash, so the verifier cannot drift from the producer: see +#: ``ca2a_verify.dag``, which refuses a link naming anything else rather than +#: comparing it against a digest it never computed. +LINK_DIGEST = "sha256" + + def trace_record_hash(signed_record: dict[str, Any]) -> str: """The ``sha256:`` digest a child hop puts in ``delegation.parent_record_hash``. @@ -204,7 +211,7 @@ def trace_record_hash(signed_record: dict[str, Any]) -> str: canonical bytes, so the link commits to the exact signed parent. Compute this only on a signed record. """ - return "sha256:" + hashlib.sha256(rfc8785.dumps(signed_record)).hexdigest() + return f"{LINK_DIGEST}:" + hashlib.new(LINK_DIGEST, rfc8785.dumps(signed_record)).hexdigest() @dataclass(frozen=True) diff --git a/src/ca2a_verify/dag.py b/src/ca2a_verify/dag.py index 7e58117..334dea0 100644 --- a/src/ca2a_verify/dag.py +++ b/src/ca2a_verify/dag.py @@ -39,8 +39,12 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey from ca2a_runtime.delegation import DelegationCredential -from ca2a_runtime.errors import ProvenanceLinkBroken, TraceRecordInvalid -from ca2a_runtime.trace_binding import trace_record_hash +from ca2a_runtime.errors import ( + ProvenanceLinkBroken, + TraceDigestUnsupported, + TraceRecordInvalid, +) +from ca2a_runtime.trace_binding import LINK_DIGEST, trace_record_hash @dataclass(frozen=True) @@ -137,8 +141,11 @@ def verify_trace_dag( check. Raises ``TraceRecordInvalid`` for a structurally invalid, untrusted, or - badly-signed record, and ``ProvenanceLinkBroken`` for a broken parent link, - a mislabeled root, or a repeated record. Returns a summary on success. + badly-signed record, ``ProvenanceLinkBroken`` for a broken parent link, + a mislabeled root, or a repeated record, and ``TraceDigestUnsupported`` for a + parent link naming a digest this verifier does not compute, which leaves the + chain unverifiable at that hop rather than invalid. Returns a summary on + success. """ if not records: raise ProvenanceLinkBroken("empty TRACE DAG") @@ -183,7 +190,16 @@ def verify_trace_dag( else: if delegation is None: raise ProvenanceLinkBroken(f"record {i} is missing its delegation block") - if delegation.get("parent_record_hash") != prev_hash: + parent_link = delegation["parent_record_hash"] + if not parent_link.startswith(f"{LINK_DIGEST}:"): + raise TraceDigestUnsupported( + f"record {i} parent link names a digest this verifier does not implement", + detail=( + f"the link is {parent_link.split(':', 1)[0]}, this verifier computes " + f"{LINK_DIGEST}; the chain is unverifiable here, not invalid" + ), + ) + if parent_link != prev_hash: raise ProvenanceLinkBroken( f"record {i} parent link does not match the previous record's hash", detail="a tampered or reparented record was detected", diff --git a/tests/unit/test_trace_binding.py b/tests/unit/test_trace_binding.py index 3ede583..b2ea7d2 100644 --- a/tests/unit/test_trace_binding.py +++ b/tests/unit/test_trace_binding.py @@ -8,16 +8,23 @@ from __future__ import annotations +import hashlib import time import pytest +import rfc8785 from agentrust_trace import generate_key, sign_record, validate_json from trace_tests.runner import run as run_conformance from ca2a_runtime.delegation import DelegationCredential -from ca2a_runtime.errors import ProvenanceLinkBroken, TraceRecordInvalid +from ca2a_runtime.errors import ( + ProvenanceLinkBroken, + TraceDigestUnsupported, + TraceRecordInvalid, +) from ca2a_runtime.trace_binding import ( EAT_PROFILE, + LINK_DIGEST, HopContext, HopSpec, build_trace_record, @@ -215,3 +222,122 @@ def test_root_with_delegation_block_rejected() -> None: def test_empty_dag_rejected() -> None: with pytest.raises(ProvenanceLinkBroken, match="empty TRACE DAG"): verify_trace_dag([], trusted_keys=[]) + + +# --- links naming a digest this verifier does not compute ------------------- +# +# The TRACE schema permits sha256: and sha384: for delegation.parent_record_hash +# and this verifier computes only LINK_DIGEST. Such a link is well formed and may +# be correct, so the outcome is unverifiable, not invalid. Before these tests the +# link was compared as a string against a digest it could never equal, and the +# mismatch was reported as ProvenanceLinkBroken, whose documented meaning is that +# tampering or reparenting was detected. + + +def _linked_dag(link_algs: list[str | None]) -> tuple[list[dict], list]: + """A correctly signed DAG, hop ``i`` linking to its parent by ``link_algs[i]``. + + ``link_algs[0]`` is the root and must be None. Every record is signed over the + link it carries, so the only difference between the arms is the algorithm. + """ + assert link_algs[0] is None + keys = [generate_key() for _ in link_algs] + records: list[dict] = [] + for i, alg in enumerate(link_algs): + if alg is None: + link = None + elif alg == LINK_DIGEST: + link = trace_record_hash(records[-1]) + else: + link = f"{alg}:" + hashlib.new(alg, rfc8785.dumps(records[-1])).hexdigest() + records.append( + sign_trace_record( + build_trace_record( + subject=f"spiffe://ca2a.example/peer/{i}", + iat=_NOW, + context=_software_context(f"peer-{i}"), + credential_id=None if i == 0 else f"cred-{i}", + parent_record_hash=link, + ), + keys[i], + ) + ) + return records, _trusted(keys) + + +def test_baseline_link_verifies() -> None: + """The control. Without it the next two tests pass on a verifier that rejects + every DAG, and the arms would differ in more than the digest.""" + records, trusted = _linked_dag([None, LINK_DIGEST]) + assert verify_trace_dag(records, trusted_keys=trusted).hops == 2 + + +def test_unsupported_link_digest_is_unverifiable_not_broken() -> None: + records, trusted = _linked_dag([None, "sha384"]) + with pytest.raises(TraceDigestUnsupported, match="does not implement") as exc: + verify_trace_dag(records, trusted_keys=trusted) + assert "sha384" in (exc.value.detail or "") + + +@pytest.mark.parametrize( + ("algs", "hop"), + [ + ([None, "sha384", LINK_DIGEST], "record 1"), + ([None, LINK_DIGEST, "sha384"], "record 2"), + ], + ids=["unreadable-deep-readable-leaf", "readable-deep-unreadable-leaf"], +) +def test_unsupported_link_is_caught_wherever_it_sits(algs: list, hop: str) -> None: + """One readable link and one unreadable one, in both orders. + + Each link is checked on its own. A verifier that reads one link's algorithm + and assumes the rest of the chain matches reports one of these two verified, + having never resolved half of it, and which one depends only on the order it + walks. Both orders are here so neither direction can mask the other. + """ + records, trusted = _linked_dag(algs) + with pytest.raises(TraceDigestUnsupported, match=hop): + verify_trace_dag(records, trusted_keys=trusted) + + +def test_supported_digest_still_detects_a_broken_link() -> None: + """Refusing early must not weaken what the comparison was there to catch. + + A well-formed LINK_DIGEST link, signed by the hop's own key, pointing at a + record that is not its parent. It passes the new check and must still fail + the old one, or the fix would have replaced tamper detection rather than + narrowed it. + """ + keys = [generate_key(), generate_key()] + root = sign_trace_record( + build_trace_record( + subject="spiffe://ca2a.example/peer/0", + iat=_NOW, + context=_software_context("peer-0"), + credential_id=None, + parent_record_hash=None, + ), + keys[0], + ) + decoy = sign_trace_record( + build_trace_record( + subject="spiffe://ca2a.example/peer/decoy", + iat=_NOW, + context=_software_context("decoy"), + credential_id=None, + parent_record_hash=None, + ), + keys[0], + ) + child = sign_trace_record( + build_trace_record( + subject="spiffe://ca2a.example/peer/1", + iat=_NOW, + context=_software_context("peer-1"), + credential_id="cred-1", + parent_record_hash=trace_record_hash(decoy), + ), + keys[1], + ) + with pytest.raises(ProvenanceLinkBroken, match="parent link does not match"): + verify_trace_dag([root, child], trusted_keys=_trusted(keys))