From 73dc90992f6ddebbf833bd88b47235e0c219d14e Mon Sep 17 00:00:00 2001 From: Murtaza Munaim Date: Thu, 13 Aug 2026 12:06:26 -0700 Subject: [PATCH 1/3] feat(delegation): add credential validity windows (not_before/not_after) Optional not_before / not_after fields (Unix epoch seconds, inclusive) on DelegationCredential, enforced per hop by verify_chain at a caller-supplied at_time defaulting to the current time, and threaded through ca2a_verify.verify_delegation_chain, verify_chain_file, and the verify-chain / verify-dag CLI as --at-time. An absent bound is omitted from the signed body rather than encoded as null, so every previously signed credential keeps its exact signed bytes; a present bound is signed, so it cannot be stripped without failing verification. The a2a-sdk bridge restores the bounds' integer-ness across the protobuf Struct round trip exactly as it already did for depth. New error codes CREDENTIAL_EXPIRED and CREDENTIAL_NOT_YET_VALID. Conformance DELEG-007..009 and ACTION-012/013 cover the expired and not-yet-valid cases from the #36 residual checklist. Refs #36 Co-Authored-By: Claude Fable 5 Signed-off-by: Murtaza Munaim --- CHANGELOG.md | 2 + docs/spec/delegation-chain.md | 27 ++++++ docs/spec/error-codes.md | 6 +- docs/spec/verification-library.md | 6 +- src/ca2a_runtime/cli.py | 16 ++- src/ca2a_runtime/delegation/credential.py | 97 ++++++++++++++++--- src/ca2a_runtime/errors.py | 18 ++++ src/ca2a_runtime/transport/a2a_sdk.py | 17 ++-- src/ca2a_verify/verify.py | 17 +++- tests/conformance/README.md | 5 + tests/conformance/test_profile_conformance.py | 57 ++++++++++- tests/unit/conftest.py | 17 +++- tests/unit/test_a2a_sdk_bridge.py | 23 +++++ tests/unit/test_delegation.py | 78 +++++++++++++++ 14 files changed, 347 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b467678..95b32ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Delegation credentials can now carry a validity window (#36).** Optional `not_before` / `not_after` fields (Unix epoch seconds, inclusive at both ends) on `DelegationCredential`, enforced per hop by `verify_chain` at a caller-supplied `at_time` defaulting to the current time, and threaded through `ca2a_verify.verify_delegation_chain`, `verify_chain_file`, and `ca2a verify-chain` / `verify-dag` as `--at-time`. An absent bound is omitted from the signed body rather than encoded as null, so every previously signed credential keeps its exact signed bytes; a bound that is present is signed, so it cannot be stripped without failing verification. The a2a-sdk bridge restores the bounds' integer-ness across the protobuf `Struct` round trip exactly as it already did for `depth`. New error codes `CREDENTIAL_EXPIRED` and `CREDENTIAL_NOT_YET_VALID`; conformance `DELEG-007`–`DELEG-009` and `ACTION-012`/`ACTION-013` cover the expired and not-yet-valid cases from the #36 action-evidence checklist. + - **A bridge to the official `a2a-sdk`, so cA2A reaches the SDK that A2A agents actually run (#91).** cA2A describes itself as a profile on A2A and, until now, integrated with no A2A implementation: `transport.a2a_adapter` parsed A2A-shaped dicts and `transport.server` was a bespoke standard-library HTTP server. Both are honest about being a *reference*, but the practical effect was that a team already running the official SDK could only adopt the profile by replacing their transport with ours, which nobody does to try an alpha. A2A reached v1.0 in April 2026 under the Linux Foundation with SDKs in six languages, and is wired into Google ADK, Azure AI Foundry, Amazon Bedrock AgentCore and Copilot Studio; the profile reached none of it. `ca2a_runtime.transport.a2a_sdk` is deliberately thin. The SDK carries A2A `metadata` as a `google.protobuf.Struct`, so converting that to a plain mapping hands the existing adapter exactly what it already parses: one parser, one set of tests, and the profile stays transport-agnostic. Optional extra (`pip install 'ca2a[a2a-sdk]'`); the base install still depends on no A2A implementation. diff --git a/docs/spec/delegation-chain.md b/docs/spec/delegation-chain.md index bc906b3..effb4f6 100644 --- a/docs/spec/delegation-chain.md +++ b/docs/spec/delegation-chain.md @@ -15,6 +15,8 @@ A `DelegationCredential` has the following signed body plus a detached signature | `depth` | int | 0 at the root, +1 per hop | | `parent_id` | string or null | `credential_id` of the parent hop; null at the root | | `signature` | hex | Ed25519 over the canonical body, by the issuer | +| `not_before` | int, optional | Unix epoch seconds; the credential is not valid before this time (inclusive) | +| `not_after` | int, optional | Unix epoch seconds; the credential is not valid after this time (inclusive) | ## Canonicalization @@ -26,6 +28,12 @@ be a non-negative JSON integer (not a boolean or float), and `scope` must be a non-empty array of unique non-empty strings. This ensures the object accepted by one implementation is the same signed object another implementation sees. +An absent validity bound is omitted from the body, not encoded as null: emitting +nulls would change the canonical bytes of every credential signed before the +fields existed. A bound that is present is part of the signed body (and must be +a non-negative JSON integer, never null), so it cannot be stripped or altered +without invalidating the signature. + ## Verification invariants `verify_chain` raises the specific error for the first invariant that fails: @@ -39,6 +47,7 @@ one implementation is the same signed object another implementation sees. | Each hop's depth is previous + 1, and at most `max_depth` | `BROKEN_DELEGATION_LINK` / `DELEGATION_DEPTH_EXCEEDED` | | Each hop's scope is a subset of its parent's scope | `SCOPE_ESCALATION` | | No `credential_id` repeats | `CREDENTIAL_REPLAY` | +| Each hop's validity window, when present, contains the evaluation time | `CREDENTIAL_NOT_YET_VALID` / `CREDENTIAL_EXPIRED` | | The root issuer is pinned by the callee for runtime authorization | `UNTRUSTED_DELEGATION_ROOT` | Signature validity establishes who issued a chain; it does not establish that @@ -48,6 +57,24 @@ root is absent. Offline tooling may omit that set when it only needs to check a chain's internal structure, but structural verification alone does not authorize work. +## Validity window + +`not_before` / `not_after` bound when a credential may be used, as Unix epoch +seconds, inclusive at both ends. Either bound may appear alone; an absent bound +means unbounded on that side, which is exactly what every credential issued +before these fields existed already says. + +`verify_chain` checks every hop's window against a single evaluation time: +`at_time` when the caller supplies one, the current time otherwise. Live +authorization always evaluates now. Offline audit of recorded evidence should +pass the time the action was decided (`ca2a verify-chain --at-time`), because a +window that has lapsed by audit time says nothing about validity at decision +time. + +Windows are not required to nest across hops. A chain is usable only at times +inside every hop's window, so the effective window is already the intersection +of the hops'; requiring structural nesting would add no authority bound. + ## Attenuation is the whole point Attenuation, the guarantee that a child grant cannot exceed its parent, is the confused-deputy defense. Without it, B could accept a narrow task from A and then act with authority A never granted. The subset check on `scope` at every hop is what forecloses that. diff --git a/docs/spec/error-codes.md b/docs/spec/error-codes.md index 5039a5f..58688de 100644 --- a/docs/spec/error-codes.md +++ b/docs/spec/error-codes.md @@ -16,6 +16,8 @@ An error also carries a human-readable message and an optional `detail`. The mes | `BrokenDelegationLink` | `BROKEN_DELEGATION_LINK` | 409 | A hop does not chain to its stated parent, or continuity is broken: empty chain, a root credential that names a parent or has nonzero depth, a hop whose parent link or subject does not match the previous hop, or a hop depth that is not previous + 1. | | `DelegationDepthExceeded` | `DELEGATION_DEPTH_EXCEEDED` | 403 | A chain is longer than the configured `max_delegation_depth`. Raised by `verify_chain`. | | `CredentialReplay` | `CREDENTIAL_REPLAY` | 409 | A `credential_id` appears more than once in a single chain. Raised by `verify_chain`. | +| `CredentialNotYetValid` | `CREDENTIAL_NOT_YET_VALID` | 403 | A hop's `not_before` bound is after the evaluation time. The chain is well formed and validly signed, but the grant is not yet in force. Raised by `verify_chain`. | +| `CredentialExpired` | `CREDENTIAL_EXPIRED` | 403 | A hop's `not_after` bound is before the evaluation time. Raised by `verify_chain`. | | `HolderProofInvalid` | `HOLDER_PROOF_INVALID` | 401 | The presenter of a delegation chain did not prove it controls the leaf `subject`: no proof was presented, the proof was malformed, it answered a challenge this callee did not issue or which has expired, or its signature did not verify over the exact request being made. 401 rather than 403 because the chain may well carry the authority requested while the caller has not shown it is the party that authority was delegated to. Distinct from `ATTESTATION_FAILED`, which is about what the caller is *running*: a caller can appraise perfectly and still fail this. Raised by `verify_holder_proof`, `handle_peer_request`, and the A2A adapter on a malformed proof. See [profile](profile.md) P-4a. | | `AttestationUnsupported` | `ATTESTATION_UNSUPPORTED` | 500 | An attestation provider was requested that the host cannot supply. Raised by any provider's `attest` when the host lacks what its collector needs, and by `OpaqueProvider`, which has no collector. The `detail` names the missing piece. See [Peer Attestation](attestation.md). | | `AttestationFailed` | `ATTESTATION_FAILED` | 412 | Attestation evidence was present but did not verify. Raised by the SEV-SNP verifier on a malformed report, an untrusted or broken certificate chain, a bad report signature, or a measurement / report-data mismatch. See [Peer Attestation](attestation.md). | @@ -26,7 +28,7 @@ An error also carries a human-readable message and an optional `detail`. The mes ## Which errors are live today -`ConfigError`, `InvalidCredential`, `ScopeEscalation`, `BrokenDelegationLink`, `DelegationDepthExceeded`, `CredentialReplay`, 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`, 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`. `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). @@ -52,7 +54,7 @@ Verification fails closed. `verify_chain`, `verify_dag`, and `cross_check_chain` ## See also -- [Delegation Chain](delegation-chain.md) for the checks behind `ScopeEscalation`, `BrokenDelegationLink`, `DelegationDepthExceeded`, and `CredentialReplay`. +- [Delegation Chain](delegation-chain.md) for the checks behind `ScopeEscalation`, `BrokenDelegationLink`, `DelegationDepthExceeded`, `CredentialReplay`, `CredentialNotYetValid`, and `CredentialExpired`. - [Provenance DAG](provenance-dag.md) for the checks behind `ProvenanceLinkBroken`. - [Verification Library](verification-library.md) for `verify_chain`, `verify_chain_file`, `verify_dag`, and `cross_check_chain`. - [Failure Modes](failure-modes.md) for how these errors map to observable runtime behavior. diff --git a/docs/spec/verification-library.md b/docs/spec/verification-library.md index 8fc2467..52fe172 100644 --- a/docs/spec/verification-library.md +++ b/docs/spec/verification-library.md @@ -11,8 +11,10 @@ result: ChainResult = verify_chain_file("chain.json") # result.hops, result.root_issuer, result.leaf_subject, result.leaf_scope ``` -- `verify_delegation_chain(chain, max_depth=8)` verifies a list of `DelegationCredential` and returns a `ChainResult` summary, or raises a `CA2AError` subtype. -- `verify_chain_file(path, max_depth=8)` loads a chain from JSON (a bare list, or `{"chain": [...]}`) and verifies it. +- `verify_delegation_chain(chain, max_depth=8, at_time=None)` verifies a list of `DelegationCredential` and returns a `ChainResult` summary, or raises a `CA2AError` subtype. +- `verify_chain_file(path, max_depth=8, at_time=None)` loads a chain from JSON (a bare list, or `{"chain": [...]}`) and verifies it. + +`at_time` is the Unix time validity windows are evaluated at; `None` means the current time. An auditor replaying recorded evidence passes the time the action was decided, not its own. See [delegation chain](delegation-chain.md). ## Errors diff --git a/src/ca2a_runtime/cli.py b/src/ca2a_runtime/cli.py index 4b3bcf5..53278f9 100644 --- a/src/ca2a_runtime/cli.py +++ b/src/ca2a_runtime/cli.py @@ -33,7 +33,7 @@ def _cmd_validate_config(args: argparse.Namespace) -> int: def _cmd_verify_chain(args: argparse.Namespace) -> int: try: - result = verify_chain_file(Path(args.chain), max_depth=args.max_depth) + result = verify_chain_file(Path(args.chain), max_depth=args.max_depth, at_time=args.at_time) except CA2AError as exc: print(json.dumps({"verified": False, "code": exc.code, "error": str(exc)})) return 1 @@ -113,7 +113,7 @@ def _cmd_verify_dag(args: argparse.Namespace) -> int: cross_checked = False if args.chain: chain = _load_chain(args.chain) - verify_chain(chain, max_depth=args.max_depth) + verify_chain(chain, max_depth=args.max_depth, at_time=args.at_time) cross_check_chain(records, chain) cross_checked = True except CA2AError as exc: @@ -200,6 +200,12 @@ def build_parser() -> argparse.ArgumentParser: vch = sub.add_parser("verify-chain", help="Verify a delegation chain offline") vch.add_argument("--chain", required=True) vch.add_argument("--max-depth", type=int, default=8) + vch.add_argument( + "--at-time", + type=int, + default=None, + help="Unix time validity windows are evaluated at (default: now)", + ) vch.set_defaults(func=_cmd_verify_chain) vd = sub.add_parser("verify-dag", help="Verify a provenance DAG offline") @@ -209,6 +215,12 @@ def build_parser() -> argparse.ArgumentParser: help="Optional delegation chain to cross-check the DAG against", ) vd.add_argument("--max-depth", type=int, default=8) + vd.add_argument( + "--at-time", + type=int, + default=None, + help="Unix time validity windows are evaluated at (default: now)", + ) vd.set_defaults(func=_cmd_verify_dag) st = sub.add_parser( diff --git a/src/ca2a_runtime/delegation/credential.py b/src/ca2a_runtime/delegation/credential.py index 022a796..301fabe 100644 --- a/src/ca2a_runtime/delegation/credential.py +++ b/src/ca2a_runtime/delegation/credential.py @@ -3,13 +3,15 @@ A delegation credential is a signed statement that ``issuer`` grants ``subject`` a set of capability strings (``scope``), optionally as a child of ``parent_id``. A chain is a list of credentials ordered from root to leaf. Verification enforces -four invariants: +five invariants: 1. Signature: each credential verifies against its issuer's Ed25519 public key. 2. Continuity: each hop's issuer is the previous hop's subject. 3. Attenuation: each hop's scope is a subset of its parent's scope. 4. Anti-replay: parent_id links to the previous credential_id and every credential_id in the chain is unique. +5. Validity: each hop's validity window, when present, contains the + evaluation time. Canonicalization uses RFC 8785 (JSON Canonicalization Scheme), so the signed byte string is identical across conforming implementations and cA2A signatures @@ -19,8 +21,9 @@ from __future__ import annotations import re +import time from collections.abc import Collection -from dataclasses import dataclass +from dataclasses import dataclass, replace from typing import Any from cryptography.exceptions import InvalidSignature @@ -32,6 +35,8 @@ from ca2a_runtime.canonical import canonicalize from ca2a_runtime.errors import ( BrokenDelegationLink, + CredentialExpired, + CredentialNotYetValid, CredentialReplay, DelegationDepthExceeded, InvalidCredential, @@ -41,9 +46,12 @@ _HEX_32_RE = re.compile(r"[0-9a-f]{64}") _HEX_64_RE = re.compile(r"[0-9a-f]{128}") -_CREDENTIAL_FIELDS = frozenset( +_REQUIRED_CREDENTIAL_FIELDS = frozenset( {"credential_id", "issuer", "subject", "scope", "depth", "parent_id", "signature"} ) +# Validity bounds are optional on the wire: absent means unbounded on that side, +# and a credential issued before these fields existed keeps its exact signed bytes. +_OPTIONAL_CREDENTIAL_FIELDS = frozenset({"not_before", "not_after"}) def new_keypair() -> tuple[Ed25519PrivateKey, str]: @@ -74,10 +82,18 @@ class DelegationCredential: depth: int parent_id: str | None = None signature: str = "" # Ed25519 signature over canonical_bytes(body), hex + not_before: int | None = None # Unix epoch seconds, inclusive + not_after: int | None = None # Unix epoch seconds, inclusive def body(self) -> dict[str, Any]: - """The signed portion of the credential (everything but the signature).""" - return { + """The signed portion of the credential (everything but the signature). + + An absent validity bound is omitted rather than encoded as null: + emitting nulls would change the canonical bytes of every credential + signed before the fields existed. A bound that is present is signed, + so it cannot be stripped without invalidating the signature. + """ + payload: dict[str, Any] = { "credential_id": self.credential_id, "issuer": self.issuer, "subject": self.subject, @@ -85,6 +101,11 @@ def body(self) -> dict[str, Any]: "depth": self.depth, "parent_id": self.parent_id, } + if self.not_before is not None: + payload["not_before"] = self.not_before + if self.not_after is not None: + payload["not_after"] = self.not_after + return payload def sign(self, private_key: Ed25519PrivateKey) -> DelegationCredential: """Return a copy signed by ``private_key`` (must match ``issuer``).""" @@ -94,16 +115,11 @@ def sign(self, private_key: Ed25519PrivateKey) -> DelegationCredential: "signing key does not match credential issuer", detail=f"issuer={self.issuer} key={expected}", ) + # dataclasses.replace rather than field-by-field reconstruction: a field + # added to the model but forgotten here would be silently dropped from + # every credential this ever signs. sig = private_key.sign(canonical_bytes(self.body())).hex() - return DelegationCredential( - credential_id=self.credential_id, - issuer=self.issuer, - subject=self.subject, - scope=self.scope, - depth=self.depth, - parent_id=self.parent_id, - signature=sig, - ) + return replace(self, signature=sig) def verify_signature(self) -> None: """Raise InvalidCredential if the signature does not verify.""" @@ -119,8 +135,8 @@ def verify_signature(self) -> None: @classmethod def from_dict(cls, data: dict[str, Any]) -> DelegationCredential: - unknown = set(data) - _CREDENTIAL_FIELDS - missing = _CREDENTIAL_FIELDS - set(data) + unknown = set(data) - _REQUIRED_CREDENTIAL_FIELDS - _OPTIONAL_CREDENTIAL_FIELDS + missing = _REQUIRED_CREDENTIAL_FIELDS - set(data) if unknown or missing: raise InvalidCredential( "malformed credential fields", @@ -134,6 +150,8 @@ def from_dict(cls, data: dict[str, Any]) -> DelegationCredential: depth = data["depth"] parent_id = data["parent_id"] signature = data["signature"] + not_before = data.get("not_before") + not_after = data.get("not_after") if not isinstance(credential_id, str) or not credential_id: raise InvalidCredential("credential_id must be a non-empty string") @@ -156,6 +174,19 @@ def from_dict(cls, data: dict[str, Any]) -> DelegationCredential: raise InvalidCredential( "signature must be a lowercase 64-byte Ed25519 signature in hex" ) + # A present bound must be a real integer; an explicit null is rejected + # because the signed body never carries one (absent bounds are omitted), + # so null would be a second wire form for the same signed object. + for name, bound in (("not_before", not_before), ("not_after", not_after)): + if name in data and ( + isinstance(bound, bool) or not isinstance(bound, int) or bound < 0 + ): + raise InvalidCredential(f"{name} must be a non-negative integer when present") + if not_before is not None and not_after is not None and not_before > not_after: + raise InvalidCredential( + "validity window is inverted", + detail=f"not_before={not_before} > not_after={not_after}", + ) return cls( credential_id=credential_id, @@ -165,6 +196,8 @@ def from_dict(cls, data: dict[str, Any]) -> DelegationCredential: depth=depth, parent_id=parent_id, signature=signature, + not_before=not_before, + not_after=not_after, ) @@ -173,16 +206,25 @@ def verify_chain( *, max_depth: int = 8, trusted_root_issuers: Collection[str] | None = None, + at_time: int | None = None, ) -> None: """Verify a root-to-leaf delegation chain, raising on the first violation. A well-formed chain of length N delegates from the root issuer down to the leaf subject with monotonically narrowing scope. Raises the specific CA2AError subtype for the invariant that failed. + + ``at_time`` is the Unix time validity windows are evaluated at; ``None`` + means the current time. Live authorization always evaluates now. An auditor + replaying recorded evidence passes the time the action was decided, since a + window that has lapsed by audit time says nothing about validity at + decision time. """ if not chain: raise BrokenDelegationLink("empty delegation chain") + now = int(time.time()) if at_time is None else at_time + # ``None`` deliberately means structural/offline verification only. Runtime # authorization always supplies its local trust set, including an empty set, # so a self-consistent chain minted by an attacker cannot authorize a call. @@ -198,6 +240,29 @@ def verify_chain( for i, cred in enumerate(chain): cred.verify_signature() + # Window checks come after the signature so the bounds being judged are + # the ones the issuer signed, and before the structural checks so an + # expired hop is named as expired rather than as some downstream break. + if ( + cred.not_before is not None + and cred.not_after is not None + and cred.not_before > cred.not_after + ): + raise InvalidCredential( + f"hop {i} validity window is inverted", + detail=f"not_before={cred.not_before} > not_after={cred.not_after}", + ) + if cred.not_before is not None and now < cred.not_before: + raise CredentialNotYetValid( + f"hop {i} credential is not yet valid", + detail=f"not_before={cred.not_before} at_time={now}", + ) + if cred.not_after is not None and now > cred.not_after: + raise CredentialExpired( + f"hop {i} credential has expired", + detail=f"not_after={cred.not_after} at_time={now}", + ) + if cred.credential_id in seen_ids: raise CredentialReplay(f"duplicate credential_id at hop {i}: {cred.credential_id}") seen_ids.add(cred.credential_id) diff --git a/src/ca2a_runtime/errors.py b/src/ca2a_runtime/errors.py index 6e4d0b4..aba1d75 100644 --- a/src/ca2a_runtime/errors.py +++ b/src/ca2a_runtime/errors.py @@ -59,6 +59,24 @@ class CredentialReplay(CA2AError): http_status = 409 +class CredentialNotYetValid(CA2AError): + """A credential's ``not_before`` bound is after the evaluation time. + + 403 like the other validity failures: the chain is well formed and validly + signed, but the grant is not in force at the time being evaluated. + """ + + code = "CREDENTIAL_NOT_YET_VALID" + http_status = 403 + + +class CredentialExpired(CA2AError): + """A credential's ``not_after`` bound is before the evaluation time.""" + + code = "CREDENTIAL_EXPIRED" + http_status = 403 + + class HolderProofInvalid(CA2AError): """The presenter of a delegation chain did not prove it holds the leaf key. diff --git a/src/ca2a_runtime/transport/a2a_sdk.py b/src/ca2a_runtime/transport/a2a_sdk.py index fa5bcf1..6276285 100644 --- a/src/ca2a_runtime/transport/a2a_sdk.py +++ b/src/ca2a_runtime/transport/a2a_sdk.py @@ -13,10 +13,12 @@ verifies, enforces, or appraises; it converts and delegates. **The Struct round trip loses integer-ness.** ``Struct`` has no integer type, so -a credential's ``depth`` of ``0`` comes back as ``0.0``. This bridge restores -only finite integral depth values before handing metadata to the strict parser. -A non-integral value remains a float and is rejected rather than truncated. -``tests/unit/test_a2a_sdk_bridge.py`` holds both sides of that boundary. +a credential's ``depth`` of ``0`` comes back as ``0.0``, and its validity bounds +(``not_before`` / ``not_after``) suffer the same fate. This bridge restores only +finite integral values of those fields before handing metadata to the strict +parser. A non-integral value remains a float and is rejected rather than +truncated. ``tests/unit/test_a2a_sdk_bridge.py`` holds both sides of that +boundary. Install with the extra:: @@ -80,9 +82,10 @@ def metadata_from_sdk_message(message: Any) -> dict[str, Any]: for credential in chain: if not isinstance(credential, dict): continue - depth = credential.get("depth") - if isinstance(depth, float) and depth.is_integer(): - credential["depth"] = int(depth) + for field in ("depth", "not_before", "not_after"): + value = credential.get(field) + if isinstance(value, float) and value.is_integer(): + credential[field] = int(value) return result diff --git a/src/ca2a_verify/verify.py b/src/ca2a_verify/verify.py index 3ea6417..31ef41c 100644 --- a/src/ca2a_verify/verify.py +++ b/src/ca2a_verify/verify.py @@ -30,10 +30,15 @@ class ChainResult: def verify_delegation_chain( - chain: list[DelegationCredential], *, max_depth: int = 8 + chain: list[DelegationCredential], *, max_depth: int = 8, at_time: int | None = None ) -> ChainResult: - """Verify a root-to-leaf chain and summarize it. Raises on any violation.""" - verify_chain(chain, max_depth=max_depth) + """Verify a root-to-leaf chain and summarize it. Raises on any violation. + + ``at_time`` is the Unix time validity windows are evaluated at; ``None`` + means the current time. An auditor replaying recorded evidence passes the + time the action was decided, not its own. + """ + verify_chain(chain, max_depth=max_depth, at_time=at_time) root = chain[0] leaf = chain[-1] return ChainResult( @@ -52,7 +57,9 @@ def _parse_chain(data: Any) -> list[DelegationCredential]: return [DelegationCredential.from_dict(item) for item in data] -def verify_chain_file(path: str | Path, *, max_depth: int = 8) -> ChainResult: +def verify_chain_file( + path: str | Path, *, max_depth: int = 8, at_time: int | None = None +) -> ChainResult: """Load a delegation chain from a JSON file and verify it.""" p = Path(path) if not p.is_file(): @@ -61,4 +68,4 @@ def verify_chain_file(path: str | Path, *, max_depth: int = 8) -> ChainResult: data = json.loads(p.read_text(encoding="utf-8")) except json.JSONDecodeError as exc: raise InvalidCredential(f"invalid JSON in {p}", detail=str(exc)) from exc - return verify_delegation_chain(_parse_chain(data), max_depth=max_depth) + return verify_delegation_chain(_parse_chain(data), max_depth=max_depth, at_time=at_time) diff --git a/tests/conformance/README.md b/tests/conformance/README.md index e632d97..1eeaf11 100644 --- a/tests/conformance/README.md +++ b/tests/conformance/README.md @@ -35,6 +35,9 @@ Spec: [delegation-chain.md](../../docs/spec/delegation-chain.md) | DELEG-004 | MUST | A chain deeper than the configured maximum is rejected. | `DELEGATION_DEPTH_EXCEEDED`. | | DELEG-005 | MUST | A `credential_id` that repeats within a chain is rejected. | `CREDENTIAL_REPLAY`. | | DELEG-006 | MUST | A well-formed, strictly narrowing chain is accepted. | Verification succeeds. | +| DELEG-007 | MUST | A credential whose `not_after` is before the evaluation time is rejected. | `CREDENTIAL_EXPIRED`. | +| DELEG-008 | MUST | A credential whose `not_before` is after the evaluation time is rejected. | `CREDENTIAL_NOT_YET_VALID`. | +| DELEG-009 | MUST | A well-formed chain whose evaluation time falls within every hop's validity window is accepted. | Verification succeeds. | ## Group 2: Scope-policy intersection @@ -105,6 +108,8 @@ Spec: [trace-a2a-profile.md](../../docs/spec/trace-a2a-profile.md), [provenance- | ACTION-009 | MUST | A delegated action with a strictly attenuating multi-hop credential chain verifies. | `verified`. | | ACTION-010 | MUST | An action evidence chain with scope widening at an intermediate hop is rejected as provenance-invalid. | `SCOPE_ESCALATION`. | | ACTION-011 | MUST | Action evidence whose delegatee differs from the subject of the referenced credential is rejected as provenance-invalid. | `PROVENANCE_LINK_BROKEN`. | +| ACTION-012 | MUST | Action evidence whose delegation chain contains an expired credential is rejected as provenance-invalid. | `CREDENTIAL_EXPIRED`. | +| ACTION-013 | MUST | Action evidence whose delegation chain contains a not-yet-valid credential is rejected as provenance-invalid. | `CREDENTIAL_NOT_YET_VALID`. | ## Group 8: Holder binding diff --git a/tests/conformance/test_profile_conformance.py b/tests/conformance/test_profile_conformance.py index 5cc4ec7..a19bbbf 100644 --- a/tests/conformance/test_profile_conformance.py +++ b/tests/conformance/test_profile_conformance.py @@ -23,6 +23,8 @@ AttestationUnsupported, BrokenDelegationLink, CA2AError, + CredentialExpired, + CredentialNotYetValid, CredentialReplay, DelegationDepthExceeded, HolderProofInvalid, @@ -111,12 +113,16 @@ def _records(chain): return recs -def _action_chain() -> list[DelegationCredential]: +def _action_chain( + *, not_before: int | None = None, not_after: int | None = None +) -> list[DelegationCredential]: return build_chain( [ frozenset({"robot.move", "robot.inspect", "robot.stop"}), frozenset({"robot.move", "robot.inspect"}), - ] + ], + not_before=not_before, + not_after=not_after, ) @@ -229,6 +235,22 @@ def test_deleg_006_valid_chain_accepted() -> None: verify_chain(_narrowing()) +def test_deleg_007_expired_credential_rejected() -> None: + chain = build_chain([frozenset({"a"})], not_before=1_000, not_after=2_000) + with pytest.raises(CredentialExpired): + verify_chain(chain, at_time=3_000) + + +def test_deleg_008_not_yet_valid_credential_rejected() -> None: + chain = build_chain([frozenset({"a"})], not_before=1_000, not_after=2_000) + with pytest.raises(CredentialNotYetValid): + verify_chain(chain, at_time=500) + + +def test_deleg_009_chain_within_validity_window_accepted() -> None: + verify_chain(build_chain([frozenset({"a"})], not_before=1_000, not_after=2_000), at_time=1_500) + + # --- Group 2: Scope-policy intersection --- @@ -579,6 +601,37 @@ def test_action_011_delegatee_mismatch_is_provenance_invalid() -> None: assert result == _ActionEvidenceResult("provenance_invalid", "PROVENANCE_LINK_BROKEN") +# 2001-09-09 and 2100-01-01. The action-evidence helper replays through the +# offline verifier at the current time, so bounds this far out keep the +# expired / not-yet-valid classification unambiguous on any sane clock. +_PAST_EPOCH = 1_000_000_000 +_FUTURE_EPOCH = 4_102_444_800 + + +def test_action_012_expired_delegation_credential_is_provenance_invalid() -> None: + chain = _action_chain(not_after=_PAST_EPOCH) + records = _records(chain) + result = _verify_action_evidence( + chain, + records, + _action_evidence(records), + LocalPolicy.of(["robot.move", "robot.inspect"]), + ) + assert result == _ActionEvidenceResult("provenance_invalid", "CREDENTIAL_EXPIRED") + + +def test_action_013_not_yet_valid_delegation_credential_is_provenance_invalid() -> None: + chain = _action_chain(not_before=_FUTURE_EPOCH) + records = _records(chain) + result = _verify_action_evidence( + chain, + records, + _action_evidence(records), + LocalPolicy.of(["robot.move", "robot.inspect"]), + ) + assert result == _ActionEvidenceResult("provenance_invalid", "CREDENTIAL_NOT_YET_VALID") + + # --- Group 8: Holder binding --- diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 3fe2801..d73749b 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -27,11 +27,15 @@ def build_chain_with_keys( scopes: list[frozenset[str]], + *, + not_before: int | None = None, + not_after: int | None = None, ) -> tuple[list[DelegationCredential], list[Ed25519PrivateKey]]: """Build a signed chain and return it with each hop's subject private key. Continuity is preserved (each issuer is the previous subject) and depth - increments from 0. Callers pass narrowing scopes to exercise attenuation. + increments from 0. Callers pass narrowing scopes to exercise attenuation, + and optionally a validity window applied to every hop. The keys are what a delegate actually holds. The last one is the leaf key a holder proof has to be signed with, and keeping it is the difference between @@ -50,6 +54,8 @@ def build_chain_with_keys( scope=scope, depth=depth, parent_id=parent_id, + not_before=not_before, + not_after=not_after, ).sign(priv) chain.append(cred) subject_keys.append(next_priv) @@ -58,9 +64,14 @@ def build_chain_with_keys( return chain, subject_keys -def build_chain(scopes: list[frozenset[str]]) -> list[DelegationCredential]: +def build_chain( + scopes: list[frozenset[str]], + *, + not_before: int | None = None, + not_after: int | None = None, +) -> list[DelegationCredential]: """Build a correctly signed chain where hop i grants scopes[i].""" - return build_chain_with_keys(scopes)[0] + return build_chain_with_keys(scopes, not_before=not_before, not_after=not_after)[0] def proved_request( diff --git a/tests/unit/test_a2a_sdk_bridge.py b/tests/unit/test_a2a_sdk_bridge.py index 0824381..c322f97 100644 --- a/tests/unit/test_a2a_sdk_bridge.py +++ b/tests/unit/test_a2a_sdk_bridge.py @@ -152,6 +152,29 @@ def test_a_chain_still_verifies_after_the_struct_round_trip(hops: int) -> None: verify_chain(parsed.chain) +def test_validity_bounds_survive_the_struct_round_trip() -> None: + """``not_before`` / ``not_after`` cross the Struct boundary as doubles too, + and they are part of the signed body when present, so the signature only + survives if the bridge restores their integer-ness like it does depth's.""" + priv, pub = new_keypair() + _, sub = new_keypair() + cred = DelegationCredential( + credential_id="c0", + issuer=pub, + subject=sub, + scope=frozenset({"read"}), + depth=0, + not_before=1_000, + not_after=2_000, + ).sign(priv) + message = a2a_sdk.attach_to_sdk_message(Message(message_id="m1"), _request(chain=[cred])) + + parsed = a2a_sdk.parse_sdk_message(message) + assert parsed is not None + assert (parsed.chain[0].not_before, parsed.chain[0].not_after) == (1_000, 2_000) + verify_chain(parsed.chain, at_time=1_500) + + def test_a_tampered_depth_does_not_verify() -> None: """A non-integral Struct number is rejected instead of truncated.""" message = a2a_sdk.attach_to_sdk_message(Message(message_id="m1"), _request(chain=_chain(2))) diff --git a/tests/unit/test_delegation.py b/tests/unit/test_delegation.py index 902a46f..0de6f7a 100644 --- a/tests/unit/test_delegation.py +++ b/tests/unit/test_delegation.py @@ -2,11 +2,15 @@ from __future__ import annotations +from dataclasses import replace + import pytest from ca2a_runtime.delegation import DelegationCredential, canonical_bytes, new_keypair, verify_chain from ca2a_runtime.errors import ( BrokenDelegationLink, + CredentialExpired, + CredentialNotYetValid, CredentialReplay, DelegationDepthExceeded, InvalidCredential, @@ -143,6 +147,12 @@ def test_from_dict_malformed() -> None: ("depth", -1), ("parent_id", 7), ("signature", "00"), + ("not_before", 1.5), + ("not_before", True), + ("not_before", -1), + ("not_before", None), + ("not_after", "soon"), + ("not_after", None), ], ) def test_from_dict_rejects_type_coercion_and_invalid_crypto_fields( @@ -163,3 +173,71 @@ def test_from_dict_rejects_unsigned_extra_semantics( } with pytest.raises(InvalidCredential, match="fields"): DelegationCredential.from_dict(raw) + + +# --- Validity window --- + + +def test_windowed_credential_roundtrip_and_inclusive_bounds() -> None: + chain = build_chain([frozenset({"cap:a"})], not_before=1_000, not_after=2_000) + verify_chain(chain, at_time=1_000) + verify_chain(chain, at_time=2_000) + restored = DelegationCredential.from_dict(chain[0].body() | {"signature": chain[0].signature}) + assert restored == chain[0] + + +def test_expired_credential_rejected() -> None: + chain = build_chain([frozenset({"cap:a"})], not_before=1_000, not_after=2_000) + with pytest.raises(CredentialExpired): + verify_chain(chain, at_time=2_001) + + +def test_not_yet_valid_credential_rejected() -> None: + chain = build_chain([frozenset({"cap:a"})], not_before=1_000, not_after=2_000) + with pytest.raises(CredentialNotYetValid): + verify_chain(chain, at_time=999) + + +def test_expired_credential_rejected_by_default_clock() -> None: + # No at_time supplied: verification must evaluate at the current time + # rather than skipping the window, or an expired chain would pass on every + # existing call site by default. + chain = build_chain([frozenset({"cap:a"})], not_after=1_000) + with pytest.raises(CredentialExpired): + verify_chain(chain) + + +def test_stripping_a_signed_validity_bound_breaks_the_signature() -> None: + chain = build_chain([frozenset({"cap:a"})], not_after=2_000) + stripped = replace(chain[0], not_after=None) + with pytest.raises(InvalidCredential): + stripped.verify_signature() + + +def test_body_omits_absent_bounds(valid_chain: list[DelegationCredential]) -> None: + # Encoding absent bounds as null would change the canonical bytes of every + # credential signed before the fields existed. + body = valid_chain[0].body() + assert "not_before" not in body + assert "not_after" not in body + valid_chain[0].verify_signature() + + +def test_inverted_window_rejected_in_chain() -> None: + priv, pub = new_keypair() + _, sub = new_keypair() + cred = DelegationCredential( + "c0", pub, sub, frozenset({"cap:a"}), 0, not_before=2_000, not_after=1_000 + ).sign(priv) + with pytest.raises(InvalidCredential): + verify_chain([cred], at_time=1_500) + + +def test_from_dict_rejects_inverted_window(valid_chain: list[DelegationCredential]) -> None: + raw = valid_chain[0].body() | { + "signature": valid_chain[0].signature, + "not_before": 2_000, + "not_after": 1_000, + } + with pytest.raises(InvalidCredential, match="inverted"): + DelegationCredential.from_dict(raw) From 8d6ba7f7bc0414977e288b34f1bc8273b8676e63 Mon Sep 17 00:00:00 2001 From: Murtaza Munaim Date: Fri, 14 Aug 2026 18:00:28 -0700 Subject: [PATCH 2/3] fix(delegation): validate at_time and align the threat model with validity windows Review follow-up to #110: - verify_chain now rejects a non-integer at_time (bool, float, negative) with ValueError before any credential is examined. The bounds on the wire are strict JSON integers; the evaluation time they are compared against holds the same line for library callers. The CLI was already argparse-typed. - The residual-risk entry #107 added to the threat model now reflects that a credential can carry a validity window: the remaining gap is revocation inside a still-valid window, as agreed on #107. Co-Authored-By: Claude Fable 5 Signed-off-by: Murtaza Munaim --- docs/spec/threat-model.md | 2 +- src/ca2a_runtime/delegation/credential.py | 8 ++++++++ tests/unit/test_delegation.py | 10 ++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/docs/spec/threat-model.md b/docs/spec/threat-model.md index dcd8296..f69b1b2 100644 --- a/docs/spec/threat-model.md +++ b/docs/spec/threat-model.md @@ -43,4 +43,4 @@ Because attestation and sealing are not yet implemented (Tier 2/3), this release Closing the window entirely needs state, and the place for it is the challenge rather than the proof, so that the profile carries one such decision instead of two. A deployment that requires exactly-once should supply a stateful challenge and accept the shared-store or sticky-routing cost that comes with it. -**Delegated authority cannot be withdrawn.** A credential carries no validity window and there is no revocation path, so a delegate that is later compromised keeps whatever it was granted. This interacts with P-4's requirement that verification work offline, since an offline verifier cannot learn that a credential was revoked. +**Delegated authority cannot be actively withdrawn.** A credential can carry a validity window (`not_before` / `not_after`, see [delegation chain](delegation-chain.md)), which bounds how long a compromised delegate keeps what it was granted — but there is no revocation path, so inside a still-valid window the grant cannot be withdrawn early. This interacts with P-4's requirement that verification work offline, since an offline verifier cannot learn that a credential was revoked. diff --git a/src/ca2a_runtime/delegation/credential.py b/src/ca2a_runtime/delegation/credential.py index 301fabe..5fdb244 100644 --- a/src/ca2a_runtime/delegation/credential.py +++ b/src/ca2a_runtime/delegation/credential.py @@ -220,6 +220,14 @@ def verify_chain( window that has lapsed by audit time says nothing about validity at decision time. """ + # Bounds on the wire are strict JSON integers; the evaluation time they are + # compared against holds the same line, or True / 1.5 / -1 from a library + # caller would silently decide validity. (The CLI is already argparse-typed.) + if at_time is not None and ( + isinstance(at_time, bool) or not isinstance(at_time, int) or at_time < 0 + ): + raise ValueError("at_time must be a non-negative integer or None") + if not chain: raise BrokenDelegationLink("empty delegation chain") diff --git a/tests/unit/test_delegation.py b/tests/unit/test_delegation.py index 0de6f7a..e59d047 100644 --- a/tests/unit/test_delegation.py +++ b/tests/unit/test_delegation.py @@ -233,6 +233,16 @@ def test_inverted_window_rejected_in_chain() -> None: verify_chain([cred], at_time=1_500) +@pytest.mark.parametrize("bad_at_time", [True, 1.5, -1, "1500"]) +def test_verify_chain_rejects_non_integer_at_time( + valid_chain: list[DelegationCredential], bad_at_time: object +) -> None: + # The credential bounds are strict JSON integers; the evaluation time they + # are compared against holds the same line for library callers. + with pytest.raises(ValueError, match="at_time"): + verify_chain(valid_chain, at_time=bad_at_time) # type: ignore[arg-type] + + def test_from_dict_rejects_inverted_window(valid_chain: list[DelegationCredential]) -> None: raw = valid_chain[0].body() | { "signature": valid_chain[0].signature, From c7098a87459e1a89105278ecb68b85fa435245a4 Mon Sep 17 00:00:00 2001 From: Murtaza Munaim Date: Sun, 16 Aug 2026 07:46:34 -0700 Subject: [PATCH 3/3] fix(delegation): enforce validity bounds at construction, not only on the wire Review follow-up to #110, round 2. DelegationCredential is a public constructor as well as a wire format, and sign() would put a bound outside the documented format into a signed body that this implementation's own from_dict rejects. __post_init__ now enforces the same non-negative-integer rule and window ordering from_dict applies, so an invalid credential cannot be constructed, signed, or verified; replace() re-runs it, so sign() is covered. The now-unreachable inverted-window check in verify_chain is removed. Direct-construction tests cover bool, negative, float, and string misuse on both bounds, plus the inverted window; the from_dict tests are unchanged. Co-Authored-By: Claude Fable 5 Signed-off-by: Murtaza Munaim --- src/ca2a_runtime/delegation/credential.py | 31 ++++++++++++++++------- tests/unit/test_delegation.py | 30 +++++++++++++++++----- 2 files changed, 46 insertions(+), 15 deletions(-) diff --git a/src/ca2a_runtime/delegation/credential.py b/src/ca2a_runtime/delegation/credential.py index 5fdb244..b8999af 100644 --- a/src/ca2a_runtime/delegation/credential.py +++ b/src/ca2a_runtime/delegation/credential.py @@ -85,6 +85,26 @@ class DelegationCredential: not_before: int | None = None # Unix epoch seconds, inclusive not_after: int | None = None # Unix epoch seconds, inclusive + def __post_init__(self) -> None: + # Enforced at construction, not only in from_dict: this is also a public + # constructor, and sign() would otherwise put a bound outside the + # documented wire format into a signed body that this implementation's + # own parser rejects. replace() re-runs this, so no path around it. + for name, bound in (("not_before", self.not_before), ("not_after", self.not_after)): + if bound is not None and ( + isinstance(bound, bool) or not isinstance(bound, int) or bound < 0 + ): + raise InvalidCredential(f"{name} must be a non-negative integer when present") + if ( + self.not_before is not None + and self.not_after is not None + and self.not_before > self.not_after + ): + raise InvalidCredential( + "validity window is inverted", + detail=f"not_before={self.not_before} > not_after={self.not_after}", + ) + def body(self) -> dict[str, Any]: """The signed portion of the credential (everything but the signature). @@ -251,15 +271,8 @@ def verify_chain( # Window checks come after the signature so the bounds being judged are # the ones the issuer signed, and before the structural checks so an # expired hop is named as expired rather than as some downstream break. - if ( - cred.not_before is not None - and cred.not_after is not None - and cred.not_before > cred.not_after - ): - raise InvalidCredential( - f"hop {i} validity window is inverted", - detail=f"not_before={cred.not_before} > not_after={cred.not_after}", - ) + # An inverted window cannot reach here: __post_init__ refuses to + # construct one. if cred.not_before is not None and now < cred.not_before: raise CredentialNotYetValid( f"hop {i} credential is not yet valid", diff --git a/tests/unit/test_delegation.py b/tests/unit/test_delegation.py index e59d047..df318d4 100644 --- a/tests/unit/test_delegation.py +++ b/tests/unit/test_delegation.py @@ -223,14 +223,32 @@ def test_body_omits_absent_bounds(valid_chain: list[DelegationCredential]) -> No valid_chain[0].verify_signature() -def test_inverted_window_rejected_in_chain() -> None: - priv, pub = new_keypair() +@pytest.mark.parametrize("field", ["not_before", "not_after"]) +@pytest.mark.parametrize("value", [True, -5, 1.5, "1000"]) +def test_direct_construction_rejects_invalid_bounds(field: str, value: object) -> None: + # The dataclass is a public constructor too; without __post_init__ a bound + # outside the documented wire format could be signed into a body that this + # implementation's own from_dict rejects. + _, pub = new_keypair() _, sub = new_keypair() - cred = DelegationCredential( - "c0", pub, sub, frozenset({"cap:a"}), 0, not_before=2_000, not_after=1_000 - ).sign(priv) with pytest.raises(InvalidCredential): - verify_chain([cred], at_time=1_500) + DelegationCredential( + "c0", + pub, + sub, + frozenset({"cap:a"}), + 0, + **{field: value}, # type: ignore[arg-type] + ) + + +def test_direct_construction_rejects_inverted_window() -> None: + _, pub = new_keypair() + _, sub = new_keypair() + with pytest.raises(InvalidCredential, match="inverted"): + DelegationCredential( + "c0", pub, sub, frozenset({"cap:a"}), 0, not_before=2_000, not_after=1_000 + ) @pytest.mark.parametrize("bad_at_time", [True, 1.5, -1, "1500"])