Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/spec/error-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
2 changes: 1 addition & 1 deletion docs/spec/failure-modes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 19 additions & 0 deletions src/ca2a_runtime/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
9 changes: 8 additions & 1 deletion src/ca2a_runtime/trace_binding.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,14 +197,21 @@ 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``.

Hashes the **full signed record**, signature included, over its RFC 8785 JCS
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)
Expand Down
26 changes: 21 additions & 5 deletions src/ca2a_verify/dag.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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",
Expand Down
128 changes: 127 additions & 1 deletion tests/unit/test_trace_binding.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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))
Loading