Skip to content

Commit 20fe95d

Browse files
manizzleclaude
andcommitted
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 <noreply@anthropic.com> Signed-off-by: Murtaza Munaim <manizzle.msf@gmail.com>
1 parent 522771e commit 20fe95d

14 files changed

Lines changed: 347 additions & 39 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7070

7171
### Added
7272

73+
- **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.
74+
7375
- **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.
7476

7577
`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.

docs/spec/delegation-chain.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ A `DelegationCredential` has the following signed body plus a detached signature
1515
| `depth` | int | 0 at the root, +1 per hop |
1616
| `parent_id` | string or null | `credential_id` of the parent hop; null at the root |
1717
| `signature` | hex | Ed25519 over the canonical body, by the issuer |
18+
| `not_before` | int, optional | Unix epoch seconds; the credential is not valid before this time (inclusive) |
19+
| `not_after` | int, optional | Unix epoch seconds; the credential is not valid after this time (inclusive) |
1820

1921
## Canonicalization
2022

@@ -26,6 +28,12 @@ be a non-negative JSON integer (not a boolean or float), and `scope` must be a
2628
non-empty array of unique non-empty strings. This ensures the object accepted by
2729
one implementation is the same signed object another implementation sees.
2830

31+
An absent validity bound is omitted from the body, not encoded as null: emitting
32+
nulls would change the canonical bytes of every credential signed before the
33+
fields existed. A bound that is present is part of the signed body (and must be
34+
a non-negative JSON integer, never null), so it cannot be stripped or altered
35+
without invalidating the signature.
36+
2937
## Verification invariants
3038

3139
`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.
3947
| Each hop's depth is previous + 1, and at most `max_depth` | `BROKEN_DELEGATION_LINK` / `DELEGATION_DEPTH_EXCEEDED` |
4048
| Each hop's scope is a subset of its parent's scope | `SCOPE_ESCALATION` |
4149
| No `credential_id` repeats | `CREDENTIAL_REPLAY` |
50+
| Each hop's validity window, when present, contains the evaluation time | `CREDENTIAL_NOT_YET_VALID` / `CREDENTIAL_EXPIRED` |
4251
| The root issuer is pinned by the callee for runtime authorization | `UNTRUSTED_DELEGATION_ROOT` |
4352

4453
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
4857
chain's internal structure, but structural verification alone does not authorize
4958
work.
5059

60+
## Validity window
61+
62+
`not_before` / `not_after` bound when a credential may be used, as Unix epoch
63+
seconds, inclusive at both ends. Either bound may appear alone; an absent bound
64+
means unbounded on that side, which is exactly what every credential issued
65+
before these fields existed already says.
66+
67+
`verify_chain` checks every hop's window against a single evaluation time:
68+
`at_time` when the caller supplies one, the current time otherwise. Live
69+
authorization always evaluates now. Offline audit of recorded evidence should
70+
pass the time the action was decided (`ca2a verify-chain --at-time`), because a
71+
window that has lapsed by audit time says nothing about validity at decision
72+
time.
73+
74+
Windows are not required to nest across hops. A chain is usable only at times
75+
inside every hop's window, so the effective window is already the intersection
76+
of the hops'; requiring structural nesting would add no authority bound.
77+
5178
## Attenuation is the whole point
5279

5380
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.

docs/spec/error-codes.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ An error also carries a human-readable message and an optional `detail`. The mes
1616
| `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. |
1717
| `DelegationDepthExceeded` | `DELEGATION_DEPTH_EXCEEDED` | 403 | A chain is longer than the configured `max_delegation_depth`. Raised by `verify_chain`. |
1818
| `CredentialReplay` | `CREDENTIAL_REPLAY` | 409 | A `credential_id` appears more than once in a single chain. Raised by `verify_chain`. |
19+
| `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`. |
20+
| `CredentialExpired` | `CREDENTIAL_EXPIRED` | 403 | A hop's `not_after` bound is before the evaluation time. Raised by `verify_chain`. |
1921
| `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. |
2022
| `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). |
2123
| `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
2628

2729
## Which errors are live today
2830

29-
`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`.
31+
`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`.
3032

3133
`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).
3234

@@ -52,7 +54,7 @@ Verification fails closed. `verify_chain`, `verify_dag`, and `cross_check_chain`
5254

5355
## See also
5456

55-
- [Delegation Chain](delegation-chain.md) for the checks behind `ScopeEscalation`, `BrokenDelegationLink`, `DelegationDepthExceeded`, and `CredentialReplay`.
57+
- [Delegation Chain](delegation-chain.md) for the checks behind `ScopeEscalation`, `BrokenDelegationLink`, `DelegationDepthExceeded`, `CredentialReplay`, `CredentialNotYetValid`, and `CredentialExpired`.
5658
- [Provenance DAG](provenance-dag.md) for the checks behind `ProvenanceLinkBroken`.
5759
- [Verification Library](verification-library.md) for `verify_chain`, `verify_chain_file`, `verify_dag`, and `cross_check_chain`.
5860
- [Failure Modes](failure-modes.md) for how these errors map to observable runtime behavior.

docs/spec/verification-library.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ result: ChainResult = verify_chain_file("chain.json")
1111
# result.hops, result.root_issuer, result.leaf_subject, result.leaf_scope
1212
```
1313

14-
- `verify_delegation_chain(chain, max_depth=8)` verifies a list of `DelegationCredential` and returns a `ChainResult` summary, or raises a `CA2AError` subtype.
15-
- `verify_chain_file(path, max_depth=8)` loads a chain from JSON (a bare list, or `{"chain": [...]}`) and verifies it.
14+
- `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.
15+
- `verify_chain_file(path, max_depth=8, at_time=None)` loads a chain from JSON (a bare list, or `{"chain": [...]}`) and verifies it.
16+
17+
`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).
1618

1719
## Errors
1820

src/ca2a_runtime/cli.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ def _cmd_validate_config(args: argparse.Namespace) -> int:
3333

3434
def _cmd_verify_chain(args: argparse.Namespace) -> int:
3535
try:
36-
result = verify_chain_file(Path(args.chain), max_depth=args.max_depth)
36+
result = verify_chain_file(Path(args.chain), max_depth=args.max_depth, at_time=args.at_time)
3737
except CA2AError as exc:
3838
print(json.dumps({"verified": False, "code": exc.code, "error": str(exc)}))
3939
return 1
@@ -113,7 +113,7 @@ def _cmd_verify_dag(args: argparse.Namespace) -> int:
113113
cross_checked = False
114114
if args.chain:
115115
chain = _load_chain(args.chain)
116-
verify_chain(chain, max_depth=args.max_depth)
116+
verify_chain(chain, max_depth=args.max_depth, at_time=args.at_time)
117117
cross_check_chain(records, chain)
118118
cross_checked = True
119119
except CA2AError as exc:
@@ -200,6 +200,12 @@ def build_parser() -> argparse.ArgumentParser:
200200
vch = sub.add_parser("verify-chain", help="Verify a delegation chain offline")
201201
vch.add_argument("--chain", required=True)
202202
vch.add_argument("--max-depth", type=int, default=8)
203+
vch.add_argument(
204+
"--at-time",
205+
type=int,
206+
default=None,
207+
help="Unix time validity windows are evaluated at (default: now)",
208+
)
203209
vch.set_defaults(func=_cmd_verify_chain)
204210

205211
vd = sub.add_parser("verify-dag", help="Verify a provenance DAG offline")
@@ -209,6 +215,12 @@ def build_parser() -> argparse.ArgumentParser:
209215
help="Optional delegation chain to cross-check the DAG against",
210216
)
211217
vd.add_argument("--max-depth", type=int, default=8)
218+
vd.add_argument(
219+
"--at-time",
220+
type=int,
221+
default=None,
222+
help="Unix time validity windows are evaluated at (default: now)",
223+
)
212224
vd.set_defaults(func=_cmd_verify_dag)
213225

214226
st = sub.add_parser(

0 commit comments

Comments
 (0)