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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
27 changes: 27 additions & 0 deletions docs/spec/delegation-chain.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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.
Expand Down
6 changes: 4 additions & 2 deletions docs/spec/error-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |
Expand All @@ -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).

Expand All @@ -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.
2 changes: 1 addition & 1 deletion docs/spec/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
6 changes: 4 additions & 2 deletions docs/spec/verification-library.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 14 additions & 2 deletions src/ca2a_runtime/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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")
Expand All @@ -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(
Expand Down
Loading
Loading