Skip to content
Open
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,17 @@ 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.
- 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

Expand Down
15 changes: 9 additions & 6 deletions docs/spec/delegation-chain.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/spec/error-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ tpm = [
a2a-sdk = [
"a2a-sdk>=1.1,<2",
"aiohttp>=3.14.3",
"pyasn1>=0.6.5",
]
dev = [
"pytest>=8.0",
Expand All @@ -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]
Expand Down
3 changes: 3 additions & 0 deletions requirements/dev.in
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 6 additions & 4 deletions requirements/dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 15 additions & 2 deletions src/ca2a_runtime/delegation/credential.py
Original file line number Diff line number Diff line change
Expand Up @@ -270,8 +270,14 @@ 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``. 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.
"""
Expand Down Expand Up @@ -404,6 +410,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",
Expand Down
54 changes: 54 additions & 0 deletions tests/unit/test_revocation.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,60 @@ 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_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)
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
Expand Down
Loading