From 046d6ce8cff911cc9c755c04165e99d79b7c7a35 Mon Sep 17 00:00:00 2001 From: Srinivasa Consultants <78673153+dinakarjs@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:41:38 +0530 Subject: [PATCH 1/5] fix: reject future revocation snapshots under freshness policy Address the future-snapshot freshness case reported in agentrust-io/ca2a#267. Effective signed revocations retain precedence; no-policy offline replay is unchanged. Signed-off-by: Srinivasa Consultants <78673153+dinakarjs@users.noreply.github.com> --- CHANGELOG.md | 4 +++ src/ca2a_runtime/delegation/credential.py | 12 +++++-- tests/unit/test_revocation.py | 42 +++++++++++++++++++++++ 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index caaf5e60..971acb33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Sealing to a low-order X25519 peer key raises `SealedChannelError`, consistent with other invalid channel keys, instead of leaking a backend `ValueError`. These keys were already refused; no ciphertext format or acceptance rule changes. +- A future-dated revocation snapshot no longer satisfies an explicit + `max_revocation_staleness` policy. Effective signed revocations still take + precedence; callers receive `REVOCATION_STATUS_UNKNOWN` when freshness cannot + be established. ### Removed diff --git a/src/ca2a_runtime/delegation/credential.py b/src/ca2a_runtime/delegation/credential.py index c4245589..8c7b9497 100644 --- a/src/ca2a_runtime/delegation/credential.py +++ b/src/ca2a_runtime/delegation/credential.py @@ -270,8 +270,9 @@ def verify_chain( offline verification as "not revoked". ``max_revocation_staleness`` (seconds) makes revocation checking mandatory: - with it set, a missing snapshot, or one whose ``as_of`` is more than that - many seconds before the evaluation time, raises ``RevocationStatusUnknown``. + with it set, a missing snapshot, one dated after the evaluation time, or one + whose ``as_of`` is more than that many seconds before the evaluation time + raises ``RevocationStatusUnknown``. It defaults to ``None`` so that offline verification with no revocation data keeps working. """ @@ -404,6 +405,13 @@ def _check_revocation( ), ) + if max_staleness is not None and revocations.as_of > now: + raise RevocationStatusUnknown( + "revocation snapshot is dated in the future relative to evaluation time", + detail=( + f"as_of={revocations.as_of} at_time={now} max_revocation_staleness={max_staleness}" + ), + ) if max_staleness is not None and now - revocations.as_of > max_staleness: raise RevocationStatusUnknown( "revocation snapshot is older than this verifier accepts", diff --git a/tests/unit/test_revocation.py b/tests/unit/test_revocation.py index e7144646..745c6005 100644 --- a/tests/unit/test_revocation.py +++ b/tests/unit/test_revocation.py @@ -278,6 +278,48 @@ def test_snapshot_at_the_bound_is_accepted() -> None: assert status.checked is True +@pytest.mark.parametrize("future_offset", [1, 1_000_000]) +@pytest.mark.parametrize("max_staleness", [0, 300]) +def test_future_snapshot_under_freshness_policy_is_unknown( + future_offset: int, max_staleness: int +) -> None: + chain, _ = _chain() + with pytest.raises(RevocationStatusUnknown, match="future") as exc: + _verify( + chain, + _snapshot(as_of=NOW + future_offset), + at_time=NOW, + max_revocation_staleness=max_staleness, + ) + assert exc.value.code == "REVOCATION_STATUS_UNKNOWN" + + +def test_snapshot_at_evaluation_time_satisfies_zero_staleness() -> None: + chain, _ = _chain() + status = _verify(chain, _snapshot(), at_time=NOW, max_revocation_staleness=0) + assert status.checked is True + assert status.as_of == NOW + + +def test_future_snapshot_still_proves_effective_revocation() -> None: + chain, keys = _chain() + snapshot = _snapshot(revoke(chain[2], keys[2], issued_at=NOW), as_of=NOW + 1) + with pytest.raises(CredentialRevoked): + _verify(chain, snapshot, at_time=NOW, max_revocation_staleness=300) + + +def test_offline_verifier_propagates_future_snapshot_unknown() -> None: + chain, _ = _chain() + with pytest.raises(RevocationStatusUnknown, match="future"): + verify_delegation_chain( + chain, + trusted_root_issuers={chain[0].issuer}, + revocations=_snapshot(as_of=NOW + 1), + at_time=NOW, + max_revocation_staleness=300, + ) + + def test_stale_snapshot_still_proves_revocation() -> None: chain, keys = _chain() # Revocation is monotonic, so an old statement is still true. The refusal is From 166798d03edefe3df6de43adabcac92a3dac13df Mon Sep 17 00:00:00 2001 From: Srinivasa Consultants <78673153+dinakarjs@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:42:01 +0530 Subject: [PATCH 2/5] docs(revocation): clarify deliberate zero future clock tolerance Prepared with OpenAI Codex. Signed-off-by: Srinivasa Consultants <78673153+dinakarjs@users.noreply.github.com> --- src/ca2a_runtime/delegation/credential.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/ca2a_runtime/delegation/credential.py b/src/ca2a_runtime/delegation/credential.py index 8c7b9497..a5e090e2 100644 --- a/src/ca2a_runtime/delegation/credential.py +++ b/src/ca2a_runtime/delegation/credential.py @@ -272,7 +272,12 @@ def verify_chain( ``max_revocation_staleness`` (seconds) makes revocation checking mandatory: with it set, a missing snapshot, one dated after the evaluation time, or one whose ``as_of`` is more than that many seconds before the evaluation time - raises ``RevocationStatusUnknown``. + raises ``RevocationStatusUnknown``. Future tolerance is deliberately zero: + the accepted snapshot interval is ``[at_time - max_revocation_staleness, + at_time]``. Clock skew that places a publisher ahead of the verifier can + therefore produce an unknown status, even by one second. Deployments must + account for clock synchronization; this bound does not grant clock-skew + tolerance. An effective signed revocation still takes precedence. It defaults to ``None`` so that offline verification with no revocation data keeps working. """ From f28dcd4ccc1fb04f523136b268eceae2455ce44e Mon Sep 17 00:00:00 2001 From: Srinivasa Consultants <78673153+dinakarjs@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:47:17 +0530 Subject: [PATCH 3/5] test(revocation): cover live-clock freshness and document future bounds Exercise verify_chain without at_time and retain a recent-snapshot valid twin. Document the inclusive accepted freshness interval, zero future tolerance and effective-revocation precedence in the specification and error registry. Runtime behavior is unchanged. Prepared with OpenAI Codex. Signed-off-by: Srinivasa Dinakar Jainti --- docs/spec/delegation-chain.md | 15 +++++++++------ docs/spec/error-codes.md | 2 +- tests/unit/test_revocation.py | 11 +++++++++++ 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/docs/spec/delegation-chain.md b/docs/spec/delegation-chain.md index 039c607f..0dd3a39f 100644 --- a/docs/spec/delegation-chain.md +++ b/docs/spec/delegation-chain.md @@ -58,7 +58,7 @@ without invalidating the signature. | 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` | | No hop is revoked by its issuer or by an issuer above it (checked only when a revocation snapshot is supplied) | `CREDENTIAL_REVOKED` | -| A snapshot no older than `max_revocation_staleness` was supplied (only when that bound is set) | `REVOCATION_STATUS_UNKNOWN` | +| A snapshot with `evaluation_time - max_revocation_staleness <= as_of <= evaluation_time` was supplied (only when that bound is set) | `REVOCATION_STATUS_UNKNOWN` | Signature validity establishes who issued a chain; it does not establish that the issuer is trusted. A live callee therefore supplies its local @@ -148,12 +148,15 @@ snapshot's `as_of` (`not_revoked`). `ChainResult`, `PeerResult` and the successful verification. `max_revocation_staleness` (seconds, default unset) makes revocation checking -mandatory. With it set, a missing snapshot, or one whose `as_of` is more than -that many seconds before the evaluation time, raises -`REVOCATION_STATUS_UNKNOWN`. It is unset by default so that offline verification -with no revocation data keeps working. A stale snapshot can still prove a hop +mandatory. With it set, a missing snapshot, or one whose `as_of` is outside +`[evaluation_time - max_revocation_staleness, evaluation_time]`, raises +`REVOCATION_STATUS_UNKNOWN`. Here `evaluation_time` is `at_time` when supplied, +or the current Unix time for live verification. Future-dated snapshots are +unknown, with zero clock-skew tolerance; both interval endpoints are accepted. +The freshness policy is unset by default so that offline verification +with no revocation data keeps working. A stale or future-dated snapshot can still prove a hop revoked, since revocation is monotonic, so a revoked hop is reported as -`CREDENTIAL_REVOKED` before staleness is judged. +`CREDENTIAL_REVOKED` before freshness is judged. When `at_time` is supplied, only statements with `issued_at` at or before it count, so an audit of a past decision is not rewritten by a revocation issued diff --git a/docs/spec/error-codes.md b/docs/spec/error-codes.md index cbe84d15..10dbb4aa 100644 --- a/docs/spec/error-codes.md +++ b/docs/spec/error-codes.md @@ -21,7 +21,7 @@ An error also carries a human-readable message and an optional `detail`. The mes | `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`. | | `CredentialRevoked` | `CREDENTIAL_REVOKED` | 403 | A hop's credential was revoked by its issuer or by an issuer above it in the chain, according to the revocation snapshot the verifier supplied. Every chain containing that hop is refused. Raised by `verify_chain` only when a snapshot is supplied. See [delegation chain](delegation-chain.md#ca2a-delegation-revocation). | -| `RevocationStatusUnknown` | `REVOCATION_STATUS_UNKNOWN` | 503 | The verifier set `max_revocation_staleness` and has no revocation snapshot, or its snapshot's `as_of` is older than that bound at the evaluation time. Nothing is wrong with the chain; the verifier cannot currently establish that it has not been revoked, and fails closed. Raised by `verify_chain`. | +| `RevocationStatusUnknown` | `REVOCATION_STATUS_UNKNOWN` | 503 | The verifier set `max_revocation_staleness` and has no revocation snapshot, or its snapshot's `as_of` is outside `[evaluation_time - max_revocation_staleness, evaluation_time]`. The evaluation time is `at_time` when supplied, otherwise the current Unix time. Future-dated snapshots are unknown with zero clock-skew tolerance; both interval endpoints are accepted. An effective signed revocation still takes precedence over freshness. Nothing is wrong with the chain; the verifier cannot currently establish that it has not been revoked, and fails closed. Raised by `verify_chain`. | | `InvalidRevocation` | `INVALID_REVOCATION` | 400 | A revocation statement or snapshot is malformed, unsigned, or carries a signature that does not verify, or a snapshot file is missing or not valid JSON. A snapshot with any such statement is refused as a whole. Raised by `RevocationStatement`, `RevocationSnapshot` and `load_revocation_snapshot`. | | `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. The `detail` names the missing piece. See [Peer Attestation](attestation.md). | diff --git a/tests/unit/test_revocation.py b/tests/unit/test_revocation.py index 745c6005..deb4b983 100644 --- a/tests/unit/test_revocation.py +++ b/tests/unit/test_revocation.py @@ -294,6 +294,17 @@ def test_future_snapshot_under_freshness_policy_is_unknown( assert exc.value.code == "REVOCATION_STATUS_UNKNOWN" +def test_live_clock_rejects_future_snapshot_without_at_time() -> None: + chain, _ = _chain() + now = int(time.time()) + with pytest.raises(RevocationStatusUnknown, match="future") as exc: + _verify(chain, _snapshot(as_of=now + 3600), max_revocation_staleness=300) + assert exc.value.code == "REVOCATION_STATUS_UNKNOWN" + assert _verify( + chain, _snapshot(as_of=int(time.time()) - 10), max_revocation_staleness=300 + ).checked is True + + def test_snapshot_at_evaluation_time_satisfies_zero_staleness() -> None: chain, _ = _chain() status = _verify(chain, _snapshot(), at_time=NOW, max_revocation_staleness=0) From 80cd65aff09b85ec9bbef8fbfad4323f5998281d Mon Sep 17 00:00:00 2001 From: Srinivasa Consultants <78673153+dinakarjs@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:56:30 +0530 Subject: [PATCH 4/5] fix(deps): carry reviewed pyasn1 floor from #303 Carry the exact reviewed #303 dependency input, lock and SDK/dev-extra floors into this branch to clear the current hosted security audit failure. Preserve this PR's own runtime/tests and changelog entry. No security gate is bypassed. Prepared with OpenAI Codex. Signed-off-by: Srinivasa Dinakar Jainti --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 ++ requirements/dev.in | 3 +++ requirements/dev.txt | 10 ++++++---- 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 971acb33..976071ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Raise the A2A SDK and development extras' `pyasn1` security floor to 0.6.5, + alongside the development/CI hash lock, clearing four dependency-audit + findings for 0.6.4. + - Sealing to a low-order X25519 peer key raises `SealedChannelError`, consistent with other invalid channel keys, instead of leaking a backend `ValueError`. These keys were already refused; no ciphertext format or acceptance rule changes. diff --git a/pyproject.toml b/pyproject.toml index 4cf4691a..f6515ffe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,7 @@ tpm = [ a2a-sdk = [ "a2a-sdk>=1.1,<2", "aiohttp>=3.14.3", + "pyasn1>=0.6.5", ] dev = [ "pytest>=8.0", @@ -66,6 +67,7 @@ dev = [ "a2a-sdk[http-server]==1.2.2", "uvicorn==0.54.0", "aiohttp>=3.14.3", + "pyasn1>=0.6.5", ] [project.scripts] diff --git a/requirements/dev.in b/requirements/dev.in index ad636eff..36a848d0 100644 --- a/requirements/dev.in +++ b/requirements/dev.in @@ -25,3 +25,6 @@ a2a-sdk[http-server]==1.2.2 uvicorn==0.54.0 aiohttp>=3.14.3 setuptools>=83.0.0 + +# Security floor for google-auth's transitive ASN.1 dependency (four audit findings). +pyasn1>=0.6.5 diff --git a/requirements/dev.txt b/requirements/dev.txt index a57d254f..981e67dc 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -1710,10 +1710,12 @@ py-serializable==2.1.0 \ --hash=sha256:9d5db56154a867a9b897c0163b33a793c804c80cee984116d02d49e4578fc103 \ --hash=sha256:b56d5d686b5a03ba4f4db5e769dc32336e142fc3bd4d68a8c25579ebb0a67304 # via cyclonedx-python-lib -pyasn1==0.6.4 \ - --hash=sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81 \ - --hash=sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b - # via pyasn1-modules +pyasn1==0.6.5 \ + --hash=sha256:4017b0fce125096b8573b39c989fdb1a4b19cf761e683398184550fea0e66587 \ + --hash=sha256:6b11616a6bcf3a40e635f082bb232fa57b5e0dd7f517bd7ddd69ec3794508a47 + # via + # -r requirements/dev.in + # pyasn1-modules pyasn1-modules==0.4.2 \ --hash=sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a \ --hash=sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6 From 6130e782595ca577c82198c794571b00d5ff2a80 Mon Sep 17 00:00:00 2001 From: Srinivasa Consultants <78673153+dinakarjs@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:59:46 +0530 Subject: [PATCH 5/5] style(tests): format live-clock revocation assertion Apply the repository formatter to the new valid-twin assertion. No behavior changes. Prepared with OpenAI Codex. Signed-off-by: Srinivasa Dinakar Jainti --- tests/unit/test_revocation.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/unit/test_revocation.py b/tests/unit/test_revocation.py index deb4b983..db5e7f9d 100644 --- a/tests/unit/test_revocation.py +++ b/tests/unit/test_revocation.py @@ -300,9 +300,10 @@ def test_live_clock_rejects_future_snapshot_without_at_time() -> None: with pytest.raises(RevocationStatusUnknown, match="future") as exc: _verify(chain, _snapshot(as_of=now + 3600), max_revocation_staleness=300) assert exc.value.code == "REVOCATION_STATUS_UNKNOWN" - assert _verify( - chain, _snapshot(as_of=int(time.time()) - 10), max_revocation_staleness=300 - ).checked is True + assert ( + _verify(chain, _snapshot(as_of=int(time.time()) - 10), max_revocation_staleness=300).checked + is True + ) def test_snapshot_at_evaluation_time_satisfies_zero_staleness() -> None: