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))