From 982acfcf821a530aace581ed9e9b4570821db705 Mon Sep 17 00:00:00 2001 From: Mohammed Zoheb Shaik Date: Wed, 12 Aug 2026 20:32:04 +0400 Subject: [PATCH] fix(delegation): commit the parent link, and drop the proof replay cache Closes #106 and #104. #106. The holder proof committed every field of the request that reaches the emitted provenance record except one: `parent_record_hash`. So a party on the path could rewrite where the hop links in the DAG while leaving the proof intact, and the callee would emit a record attached to a parent the delegate never signed for. The proof already commits to `record_id`, which is what made the omission inconsistent rather than merely incomplete: committing a record's own identifier while leaving its parent link open is half a commitment to the record's identity. Committed either way now, so a root hop cannot have a parent bolted onto it and a child hop cannot have its parent swapped. Observed on 522771e before the change: a request whose `parent_record_hash` was rewritten in flight returned 200 and produced a record linked to the attacker's parent. Worse, because the cache honoured that tampered request, it consumed the proof, and the delegate's own legitimate call was then refused as already used. After: the tampered request is refused with HOLDER_PROOF_INVALID and the legitimate call succeeds. The threat model now carries the reparenting defence, and P-4a states the rule the field list follows rather than just listing fields: every field of the request that reaches the emitted record must be committed. `test_the_proof_body_commits_every_request_field_that_reaches_the_record` guards it, so the next field to arrive uncommitted fails a test rather than shipping. #104. ProofReplayCache is removed and the holder-proof path is stateless again. It made a proof single-use by remembering it, but paid for that with per-node state in a design that is deliberately stateless, and its expiry pass walked every entry on each call, so it degraded quadratically as it approached capacity. Holder binding is therefore at-most-once-per-window, bounded by the challenge TTL, which is the same guarantee ca2a_runtime.challenge documents for itself. A deployment that needs exactly-once supplies state at the challenge rather than at the proof, so the codebase carries one such decision instead of two. PeerNode no longer takes `seen_proofs` and `verify_holder_proof` no longer takes `seen`. The window is now stated in the threat model's residual risks, alongside the fact that delegated authority still cannot be withdrawn: what an adversary on the path can replay inside it is that exact call, since the proof commits to the audience, capability, record_id, parent link and payload digest, rather than a new one. 449 passed, 3 skipped (was 451; five cache tests go with the cache, and five arrive: four on the parent link and one pinning the window guarantee that the cache used to hide). ruff check, ruff format --check, mypy strict and bandit all clean over src/ and tests/. Signed-off-by: Mohammed Zoheb Shaik --- CHANGELOG.md | 6 + docs/spec/profile.md | 10 +- docs/spec/threat-model.md | 8 +- src/ca2a_runtime/delegation/holder.py | 138 ++++-------- src/ca2a_runtime/node.py | 22 -- src/ca2a_runtime/peer.py | 7 +- src/ca2a_runtime/transport/client.py | 1 + tests/conformance/test_profile_conformance.py | 1 + tests/unit/conftest.py | 1 + tests/unit/test_holder_binding.py | 196 ++++++++---------- 10 files changed, 153 insertions(+), 237 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce83cba..b467678 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **The holder proof now commits to `parent_record_hash` (#106).** It committed every other request field that reaches the emitted provenance record, and missed this one, so a party on the path could alter where the hop linked in the DAG while the proof still verified. The result was a record attached to the wrong parent: a misattributed hop rather than forged authority or widened scope, which is why it was rated low, but it was inconsistent on its own terms. The proof already commits to `record_id`, so committing a record's own identifier while leaving its parent link open was half a commitment. Committed either way, so a root hop cannot have a parent bolted onto it. The rule is now stated in P-4a and guarded by a test: every field of the request that reaches the record is committed. + +- **Removed `ProofReplayCache`, keeping the holder-proof path stateless (#104).** It made a proof single-use by remembering it, but bought that with per-node state in a design that is deliberately stateless, and its expiry pass walked every entry on each call, so it degraded quadratically as it filled. Holder binding is now at-most-once-per-window, bounded by the challenge TTL, which is the same guarantee `ca2a_runtime.challenge` documents for itself. A deployment that needs exactly-once supplies state at the challenge rather than at the proof, so the codebase carries one such decision instead of two. `PeerNode` no longer takes `seen_proofs`, and `verify_holder_proof` no longer takes `seen`. + ### Security - Runtime authorization now requires the delegation chain's root issuer to be diff --git a/docs/spec/profile.md b/docs/spec/profile.md index ed0c076..445c593 100644 --- a/docs/spec/profile.md +++ b/docs/spec/profile.md @@ -49,7 +49,9 @@ A callee MUST verify the presented delegation chain before acting: every credent ### P-4a Holder binding -A callee MUST NOT act on a delegation chain until the presenter has proved it controls the private key of the leaf credential's `subject`. The callee MUST issue the challenge the proof answers, and MUST reject a proof that does not commit to the callee's own identity, that challenge, the leaf `credential_id` and `subject`, the requested capability, the `record_id`, the sealed payload if one is present, and the caller's own offered channel key if one is present. A chain presented without such a proof MUST be refused with `HOLDER_PROOF_INVALID`. +A callee MUST NOT act on a delegation chain until the presenter has proved it controls the private key of the leaf credential's `subject`. The callee MUST issue the challenge the proof answers, and MUST reject a proof that does not commit to the callee's own identity, that challenge, the leaf `credential_id` and `subject`, the requested capability, the `record_id`, the `parent_record_hash`, the sealed payload if one is present, and the caller's own offered channel key if one is present. A chain presented without such a proof MUST be refused with `HOLDER_PROOF_INVALID`. + +The rule behind that list: **every field of the request that reaches the emitted provenance record MUST be committed.** Committing `record_id` while leaving `parent_record_hash` uncommitted would be half a commitment to the record's identity, and would let a party on the path re-parent the hop while the proof still verified. Holder binding MUST be evaluated after chain verification, so the subject is a key someone was genuinely delegated rather than one the caller asserted, and before the effective scope is computed under P-5, so a caller that has proved nothing never reaches policy evaluation and never elicits a signed denial record. @@ -59,11 +61,7 @@ The binding key is already in the credential and needs no new trust root: `subje A callee MAY accept a chain without a proof only for offline replay of recorded evidence, where no live caller exists to answer a challenge. It MUST NOT do so on a live peer path. -A callee SHOULD honour each proof at most once. `ca2a_runtime.challenge` is stateless by design and so cannot be consumed, which means single-use has to come from remembering the proof rather than the challenge; `ProofReplayCache` does that, and `PeerNode` uses one by default with a TTL matching its own challenge TTL. A callee that remembers nothing degrades to at-most-once-per-window, where a captured proof stays usable until its challenge expires. - -Where a proof is remembered, it MUST be recorded only after it has verified. Recording earlier would let a party that holds none of the keys fill the store, or insert a signature to lock the real delegate out of its own proof. - -> The cache is bounded, and the bound is honest: past its capacity the oldest entry is evicted, so a flood of distinct valid proofs can push an earlier one out and let it be replayed inside its window. Refusing new calls instead would turn the same flood into an outage. So the property is exactly-once up to capacity, degrading to the challenge window under flood. A deployment across several instances wants a shared store or sticky routing, the same caveat the challenge secret already carries. +> **Replay is bounded by the challenge, not eliminated.** `ca2a_runtime.challenge` is stateless by design and so cannot be consumed, which makes holder binding at-most-once-per-window: a captured proof stays usable until its challenge expires, and the window is the TTL. Keeping the path stateless is the deliberate trade, the same one that module documents for itself. A deployment that needs exactly-once has to supply state, and the place for it is the challenge rather than the proof, so there is one such decision in the profile instead of two. ### P-5 Effective scope diff --git a/docs/spec/threat-model.md b/docs/spec/threat-model.md index 7b49f68..dcd8296 100644 --- a/docs/spec/threat-model.md +++ b/docs/spec/threat-model.md @@ -33,8 +33,14 @@ Out of adversary scope: breaking the underlying cryptographic primitives (Ed2551 | Credential replayed into another workflow | Unique `credential_id` and parent-link checks in chain verification | | A copied chain presented by a party it was not issued to | Holder binding: the presenter must answer a callee-issued challenge with a signature under the leaf `subject` key (profile P-4a). Appraising the caller does not cover this: an attested runtime is not a claim to anyone's delegated authority | | Attacker mints a self-consistent chain from its own root | Callee pins locally trusted root issuer keys before policy evaluation | -| Reparented or forged provenance | Linked TRACE records; the DAG is verified offline against the chain | +| Reparented or forged provenance | Linked TRACE records; the DAG is verified offline against the chain. A hop cannot be reparented in flight either: the holder proof commits to `parent_record_hash`, so altering it invalidates the proof before a record is emitted | ## Residual risks in this release Because attestation and sealing are not yet implemented (Tier 2/3), this release defends bounded authority and provenance-of-intent (via signed chains) but does not yet defend peer integrity or task confidentiality at runtime. Do not rely on cA2A for confidentiality across a trust boundary until the sealed channel and a real attestation backend land. See [LIMITATIONS.md](../../LIMITATIONS.md). + +**Holder binding is at-most-once per challenge window, not exactly-once.** The challenge in [profile](profile.md) P-4a is stateless by design and so cannot be consumed, which means a proof captured in flight stays usable until its challenge expires. The window is the challenge TTL, 60 seconds by default. Inside it, an adversary on the path can replay a complete request once more; the proof commits to the audience, capability, `record_id`, `parent_record_hash` and payload digest, so what can be replayed is that exact call rather than a new one. Outside it, the proof is dead. + +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. diff --git a/src/ca2a_runtime/delegation/holder.py b/src/ca2a_runtime/delegation/holder.py index 892cf5c..99a9799 100644 --- a/src/ca2a_runtime/delegation/holder.py +++ b/src/ca2a_runtime/delegation/holder.py @@ -31,6 +31,9 @@ a read cannot be lifted onto a write; - ``payload_sha256`` -- the sealed payload, so the ciphertext cannot be swapped under an otherwise valid proof; +- ``parent_record_hash`` -- where the emitted provenance record links, so a party + on the path cannot re-parent the hop while leaving + the proof intact; - ``caller_channel_key`` -- the channel key from the caller's own offer, when it made one. **This is the join.** It ties the attested runtime and the delegated principal into one @@ -38,18 +41,23 @@ *this* enclave. Neither mechanism provides that alone. +The rule the list follows: every field of the request that reaches the emitted +record is committed. `record_id` without `parent_record_hash` would be half a +commitment to the record's identity, so both are in. + Canonicalization is JCS rather than delimiter-joining for the reason set out in ``docs/spec/attestation.md``: with a delimiter, a value containing it shifts the split without changing the digest, and ``audience`` and ``challenge`` are attacker-influenced strings. **Replay is bounded by the challenge, not eliminated.** -:mod:`ca2a_runtime.challenge` is stateless by design, so it is -at-most-once-per-window rather than exactly-once, and a captured proof replays -until the challenge expires. That bound is the challenge TTL. A deployment that -needs exactly-once wants a challenge store, which is the trade that module -documents; holder binding inherits whichever choice it makes rather than -introducing a second challenge mechanism beside it. +:mod:`ca2a_runtime.challenge` is stateless by design and so cannot be consumed, +which makes this at-most-once-per-window rather than exactly-once: a captured +proof stays usable until its challenge expires, and the window is the TTL. +Keeping the path stateless is the deliberate trade, the same one that module +documents for itself. A deployment needing exactly-once has to supply state, and +the place for it is the challenge rather than here, so the codebase carries one +such decision instead of two. This is the RFC 7800 confirmation pattern -- the same ``cnf`` semantics the TRACE layer already applies to provenance records in ``ca2a_verify.dag`` -- @@ -59,9 +67,6 @@ from __future__ import annotations import hashlib -import threading -import time -from collections import OrderedDict from dataclasses import dataclass from typing import Any @@ -72,7 +77,7 @@ ) from ca2a_runtime.canonical import canonicalize -from ca2a_runtime.challenge import DEFAULT_TTL_SECONDS, verify_challenge +from ca2a_runtime.challenge import verify_challenge from ca2a_runtime.delegation.credential import DelegationCredential from ca2a_runtime.errors import AttestationFailed, HolderProofInvalid @@ -80,79 +85,15 @@ #: mistakable for a signature over a credential body or a TRACE record. PROOF_DOMAIN = "ca2a-holder-proof-v1" -#: Default ceiling on remembered proofs. At roughly 80 bytes an entry this is a -#: few megabytes, and entries live only as long as a challenge does, so the -#: steady-state size is request rate times TTL rather than this number. -DEFAULT_MAX_REMEMBERED = 100_000 - __all__ = [ - "DEFAULT_MAX_REMEMBERED", "PROOF_DOMAIN", "HolderProof", - "ProofReplayCache", "build_holder_proof", "proof_body", "verify_holder_proof", ] -class ProofReplayCache: - """Remembers accepted proofs so each one is honoured exactly once. - - The challenge underneath is stateless and therefore cannot be consumed, so - single-use has to come from remembering the *proof* rather than the - challenge. That is what this does: a proof is recorded once it has verified, - and a second presentation of the same signature is refused. - - **Entries only need to outlive the challenge they answer.** A proof whose - challenge has expired is already refused by :func:`verify_challenge`, so - nothing is gained by remembering it longer. ``ttl_seconds`` must therefore be - at least the challenge TTL, or a proof could be forgotten while still - otherwise valid; :class:`~ca2a_runtime.node.PeerNode` passes its own - challenge TTL for exactly that reason. - - **Bounded, and honest about what the bound costs.** Past ``max_entries`` the - oldest entry is evicted, so a flood of distinct valid proofs can push an - earlier one out and let it be replayed inside its window. Refusing new calls - instead would turn the same flood into an outage, which is worse. So the - guarantee is exactly-once up to the cache's capacity, degrading to the - challenge window under flood, rather than exactly-once unconditionally. - """ - - def __init__( - self, - *, - ttl_seconds: float = DEFAULT_TTL_SECONDS, - max_entries: int = DEFAULT_MAX_REMEMBERED, - ) -> None: - self.ttl_seconds = ttl_seconds - self.max_entries = max_entries - self._seen: OrderedDict[str, float] = OrderedDict() - self._lock = threading.Lock() - - def record(self, signature: str) -> bool: - """Record ``signature``. Return False if it had already been recorded.""" - now = time.monotonic() - with self._lock: - self._expire(now) - if signature in self._seen: - return False - while len(self._seen) >= self.max_entries: - self._seen.popitem(last=False) - self._seen[signature] = now + self.ttl_seconds - return True - - def _expire(self, now: float) -> None: - """Drop expired entries. Caller holds the lock.""" - for sig in [s for s, expires_at in self._seen.items() if expires_at < now]: - del self._seen[sig] - - def __len__(self) -> int: - with self._lock: - self._expire(time.monotonic()) - return len(self._seen) - - def proof_body( *, audience: str, @@ -163,16 +104,22 @@ def proof_body( record_id: str, sealed_payload: bytes | None, caller_channel_key: str | None, + parent_record_hash: str | None, ) -> dict[str, Any]: """The signed body of a holder proof. + Every field of the request that reaches the emitted provenance record is + committed here, so a party on the path cannot alter the record's shape while + leaving the proof intact. + ``payload_sha256`` is the hex digest of the sealed payload, or ``None`` when the request carries none. Committing to the digest rather than the bytes keeps the signed body small and JSON-safe while still pinning the ciphertext. - ``caller_channel_key`` is ``None`` when the caller made no offer. It is a - committed field either way, so a caller cannot strip its own offer and reuse - a proof that was made while attesting. + ``caller_channel_key`` is ``None`` when the caller made no offer, and + ``parent_record_hash`` is ``None`` on a root hop. Both are committed either + way: a caller cannot strip its own offer and reuse a proof made while + attesting, and a root hop cannot have a parent bolted onto it. """ return { "domain": PROOF_DOMAIN, @@ -186,6 +133,7 @@ def proof_body( None if sealed_payload is None else hashlib.sha256(sealed_payload).hexdigest() ), "caller_channel_key": caller_channel_key, + "parent_record_hash": parent_record_hash, } @@ -225,13 +173,15 @@ def build_holder_proof( record_id: str, sealed_payload: bytes | None = None, caller_channel_key: str | None = None, + parent_record_hash: str | None = None, ) -> HolderProof: """Sign a holder proof for ``leaf`` with the delegate's private key. ``private_key`` MUST be the private half of ``leaf.subject``; signing with any other key produces a proof the callee will reject. ``caller_channel_key`` must be the channel key of the offer sent with the same request, when one is - sent, or the callee will reject the mismatch. + sent, and ``parent_record_hash`` the same value the request carries, or the + callee will reject the mismatch. """ expected = private_key.public_key().public_bytes_raw().hex() if expected != leaf.subject: @@ -248,6 +198,7 @@ def build_holder_proof( record_id=record_id, sealed_payload=sealed_payload, caller_channel_key=caller_channel_key, + parent_record_hash=parent_record_hash, ) return HolderProof(challenge=challenge, signature=private_key.sign(canonicalize(body)).hex()) @@ -262,29 +213,27 @@ def verify_holder_proof( record_id: str, sealed_payload: bytes | None = None, caller_channel_key: str | None = None, - seen: ProofReplayCache | None = None, + parent_record_hash: str | None = None, ) -> None: """Verify a holder proof against the leaf credential, or raise. The challenge is checked first, against this callee's own secret, so a proof answering a challenge nobody here issued is refused before any signature work. Then the signature must verify under ``leaf.subject`` over the exact - request being made. Then, if ``seen`` is supplied, the proof is recorded and - a second presentation of it is refused. + request being made. - Raises :class:`HolderProofInvalid` in every case. A stale or forged challenge + Raises :class:`HolderProofInvalid` in both cases. A stale or forged challenge surfaces as a holder-proof failure rather than an attestation one, because what failed is the caller's claim to the credential, not its runtime. - **The replay check comes last, and that ordering is deliberate.** Recording - before verifying would let anyone fill the cache with unverifiable junk, or - pre-insert a signature to lock a legitimate caller out of its own proof. - Recording only what has already verified means an attacker would need the - delegated key to put anything in there at all, which is the thing they do - not have. - - Without ``seen`` the guarantee is at-most-once-per-window: a captured proof - stays usable until its challenge expires. + **Replay is bounded by the challenge, not eliminated.** The challenge is + stateless by design and so cannot be consumed, which makes this + at-most-once-per-window: a captured proof stays usable until its challenge + expires, and the window is the TTL. Keeping the path stateless is the + deliberate trade, the same one :mod:`ca2a_runtime.challenge` documents for + itself; a deployment that needs exactly-once has to supply state, and the + place to put it is there rather than here, so there is one such decision in + the codebase instead of two. """ try: verify_challenge(challenge_secret, proof.challenge) @@ -303,6 +252,7 @@ def verify_holder_proof( record_id=record_id, sealed_payload=sealed_payload, caller_channel_key=caller_channel_key, + parent_record_hash=parent_record_hash, ) try: pub = Ed25519PublicKey.from_public_bytes(bytes.fromhex(leaf.subject)) @@ -312,9 +262,3 @@ def verify_holder_proof( "holder proof signature failed to verify against the leaf subject", detail="the presenter does not hold the delegated key", ) from exc - - if seen is not None and not seen.record(proof.signature): - raise HolderProofInvalid( - "this holder proof has already been used", - detail="a proof is good for one call; request a fresh challenge", - ) diff --git a/src/ca2a_runtime/node.py b/src/ca2a_runtime/node.py index 0359eaa..fba26d4 100644 --- a/src/ca2a_runtime/node.py +++ b/src/ca2a_runtime/node.py @@ -18,7 +18,6 @@ from ca2a_runtime.attestation import ChannelOffer, Verifier, attest_channel from ca2a_runtime.challenge import DEFAULT_TTL_SECONDS, generate_secret, issue_challenge from ca2a_runtime.channel import generate_channel_keypair -from ca2a_runtime.delegation.holder import ProofReplayCache from ca2a_runtime.errors import ConfigError, TransportError from ca2a_runtime.peer import ( REQUIRE_HARDWARE, @@ -32,13 +31,6 @@ from ca2a_runtime.tee.software import SoftwareProvider -class _Unset: - """Sentinel, so ``seen_proofs=None`` can mean "no cache" rather than "default".""" - - -_UNSET = _Unset() - - class PeerNode: """A callee holding a stable enclave channel key, a policy, and a provider. @@ -61,7 +53,6 @@ def __init__( caller_verifier: Verifier | None = None, challenge_ttl_seconds: int = DEFAULT_TTL_SECONDS, require_holder_proof: bool = True, - seen_proofs: ProofReplayCache | None | _Unset = _UNSET, trusted_root_issuers: Collection[str] = (), ) -> None: if require_caller_attestation not in REQUIREMENT_VALUES: @@ -85,18 +76,6 @@ def __init__( self.caller_verifier = caller_verifier self.challenge_ttl_seconds = challenge_ttl_seconds self.require_holder_proof = require_holder_proof - # A proof is honoured once. The challenge underneath is stateless and so - # cannot be consumed, so single-use comes from remembering the proof. The - # TTL matches this node's challenge TTL: a proof cannot outlive the - # challenge it answers, so nothing is gained by remembering it longer. - # A deployment behind a load balancer wants a shared store or sticky - # routing, the same caveat the challenge secret already carries; pass - # ``seen_proofs=None`` to opt out and accept the window instead. - self.seen_proofs: ProofReplayCache | None - if isinstance(seen_proofs, _Unset): - self.seen_proofs = ProofReplayCache(ttl_seconds=challenge_ttl_seconds) - else: - self.seen_proofs = seen_proofs self.trusted_root_issuers = frozenset(trusted_root_issuers) self._private_key, self.channel_public_key = generate_channel_keypair() self._challenge_secret = generate_secret() @@ -126,6 +105,5 @@ def handle(self, message: dict[str, Any]) -> PeerResult: caller_verifier=self.caller_verifier, audience=self.channel_public_key, require_holder_proof=self.require_holder_proof, - seen_proofs=self.seen_proofs, trusted_root_issuers=self.trusted_root_issuers, ) diff --git a/src/ca2a_runtime/peer.py b/src/ca2a_runtime/peer.py index 60309ab..245395a 100644 --- a/src/ca2a_runtime/peer.py +++ b/src/ca2a_runtime/peer.py @@ -44,7 +44,7 @@ from ca2a_runtime.attestation import ChannelOffer, Verifier, appraise_caller from ca2a_runtime.channel import open_sealed from ca2a_runtime.delegation.credential import DelegationCredential, verify_chain -from ca2a_runtime.delegation.holder import HolderProof, ProofReplayCache, verify_holder_proof +from ca2a_runtime.delegation.holder import HolderProof, verify_holder_proof from ca2a_runtime.errors import ( AttestationFailed, ConfigError, @@ -324,7 +324,6 @@ def verify_caller_holds_leaf( *, audience: str | None, challenge_secret: bytes | None, - seen_proofs: ProofReplayCache | None = None, ) -> None: """Bind the presented chain to the caller, or raise :class:`HolderProofInvalid`. @@ -365,7 +364,7 @@ def verify_caller_holds_leaf( caller_channel_key=( None if request.caller_offer is None else request.caller_offer.channel_public_key ), - seen=seen_proofs, + parent_record_hash=request.parent_record_hash, ) @@ -380,7 +379,6 @@ def handle_peer_request( caller_verifier: Verifier | None = None, audience: str | None = None, require_holder_proof: bool = True, - seen_proofs: ProofReplayCache | None = None, trusted_root_issuers: Collection[str] = (), ) -> PeerResult: """Run the full inbound pipeline for a parsed peer request. @@ -426,7 +424,6 @@ def handle_peer_request( request, audience=audience, challenge_secret=challenge_secret, - seen_proofs=seen_proofs, ) effective = effective_scope( diff --git a/src/ca2a_runtime/transport/client.py b/src/ca2a_runtime/transport/client.py index 7ec2fb3..94b86c7 100644 --- a/src/ca2a_runtime/transport/client.py +++ b/src/ca2a_runtime/transport/client.py @@ -163,6 +163,7 @@ def send_task( record_id=record_id, sealed_payload=sealed, caller_channel_key=(None if caller_offer is None else caller_offer.channel_public_key), + parent_record_hash=parent_record_hash, ) request = PeerRequest( chain=chain, diff --git a/tests/conformance/test_profile_conformance.py b/tests/conformance/test_profile_conformance.py index a83c8b0..5cc4ec7 100644 --- a/tests/conformance/test_profile_conformance.py +++ b/tests/conformance/test_profile_conformance.py @@ -615,6 +615,7 @@ def test_hold_002_proof_by_a_non_holder_is_refused() -> None: record_id="r0", sealed_payload=None, caller_channel_key=None, + parent_record_hash=None, ) forged = HolderProof( challenge=challenge, diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 28974f9..3fe2801 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -97,6 +97,7 @@ def proved_request( caller_channel_key=( None if caller_offer is None else caller_offer.channel_public_key # type: ignore[attr-defined] ), + parent_record_hash=parent_record_hash, ), ) diff --git a/tests/unit/test_holder_binding.py b/tests/unit/test_holder_binding.py index 85b76e2..4cfeca3 100644 --- a/tests/unit/test_holder_binding.py +++ b/tests/unit/test_holder_binding.py @@ -25,7 +25,7 @@ from ca2a_runtime.canonical import canonicalize from ca2a_runtime.channel import SealedChannel, generate_channel_keypair from ca2a_runtime.delegation import DelegationCredential, build_holder_proof -from ca2a_runtime.delegation.holder import HolderProof, ProofReplayCache, proof_body +from ca2a_runtime.delegation.holder import HolderProof, proof_body from ca2a_runtime.errors import HolderProofInvalid from ca2a_runtime.node import PeerNode from ca2a_runtime.peer import REQUIRE_ANY, PeerRequest @@ -108,6 +108,7 @@ def test_attacker_cannot_forge_a_proof_with_their_own_key() -> None: record_id="r0", sealed_payload=None, caller_channel_key=None, + parent_record_hash=None, ) forged = HolderProof(challenge=challenge, signature=mallory.sign(canonicalize(body)).hex()) req = PeerRequest( @@ -224,6 +225,26 @@ def test_expired_challenge_is_refused(monkeypatch: pytest.MonkeyPatch) -> None: _handle(req) +def test_a_proof_replays_inside_its_challenge_window() -> None: + """The bound, stated as a test rather than left to the prose. + + Holder binding is at-most-once *per challenge window*, not exactly-once: the + challenge is stateless and so cannot be consumed, so a captured request stays + usable until its challenge expires. That is a deliberate trade (#104), and a + property nobody should have to infer from a docstring. The other half of the + story is :func:`test_expired_challenge_is_refused`: once the window closes, + the same proof is dead. + + If this test ever starts failing, the path has acquired state and the + guarantee has changed. That is a decision, not a bug fix, so it should break + a test on the way through. + """ + chain, keys = _chain() + req = proved_request(chain, keys[-1], "write", "r0") + assert _handle(req).granted_capability == "write" + assert _handle(req).granted_capability == "write" + + def test_proof_for_another_audience_is_refused() -> None: """A proof made for one peer cannot be presented to another.""" chain, keys = _chain() @@ -250,6 +271,73 @@ def test_proof_does_not_transfer_across_capability_or_record() -> None: ) +def test_proof_pins_the_parent_record_hash() -> None: + """A party on the path must not be able to re-parent the hop. + + ``parent_record_hash`` flows from the request into the emitted record's parent + link. Leaving it uncommitted meant it could be altered in flight while the + proof still verified, producing a record attached to the wrong parent. The + proof already commits to ``record_id``, so committing the record's own id but + not its parent link was half a commitment (#106). + """ + chain, keys = _chain() + req = proved_request(chain, keys[-1], "write", "r0", parent_record_hash="a" * 64) + reparented = PeerRequest( + chain=chain, + requested_capability="write", + record_id="r0", + parent_record_hash="b" * 64, + holder_proof=req.holder_proof, + ) + with pytest.raises(HolderProofInvalid): + _handle(reparented) + + +def test_a_root_hop_cannot_have_a_parent_bolted_on() -> None: + """The mirror: committing ``None`` is a commitment too.""" + chain, keys = _chain() + req = proved_request(chain, keys[-1], "write", "r0") # root hop, no parent + with_parent = PeerRequest( + chain=chain, + requested_capability="write", + record_id="r0", + parent_record_hash="c" * 64, + holder_proof=req.holder_proof, + ) + with pytest.raises(HolderProofInvalid): + _handle(with_parent) + + +def test_a_matching_parent_record_hash_still_verifies() -> None: + """The positive case, so the commitment is not merely rejecting everything.""" + chain, keys = _chain() + req = proved_request(chain, keys[-1], "write", "r0", parent_record_hash="d" * 64) + assert _handle(req).granted_capability == "write" + + +def test_the_proof_body_commits_every_request_field_that_reaches_the_record() -> None: + """A guard against the next field arriving uncommitted. + + Fields on the request that shape the emitted record must appear in the signed + body. This is the check that would have caught #106 when the proof was written. + """ + committed = set( + proof_body( + audience="a", + challenge="c", + credential_id="cid", + subject="s", + requested_capability="cap", + record_id="rid", + sealed_payload=None, + caller_channel_key=None, + parent_record_hash=None, + ) + ) + assert {"requested_capability", "record_id", "parent_record_hash"} <= committed + assert {"payload_sha256", "caller_channel_key"} <= committed + + def test_proof_pins_the_sealed_payload() -> None: """The ciphertext cannot be swapped under an otherwise valid proof.""" chain, keys = _chain() @@ -282,111 +370,6 @@ def test_proof_must_be_signed_by_the_leaf_not_an_ancestor() -> None: ) -# -------------------------------------------------------------------------- -# Single use: a proof is honoured once, not once per window -# -------------------------------------------------------------------------- - - -def test_a_proof_is_honoured_once() -> None: - """The whole request, valid proof included, cannot be replayed.""" - chain, keys = _chain() - seen = ProofReplayCache() - req = proved_request(chain, keys[-1], "write", "r0") - - assert _handle(req, seen_proofs=seen).granted_capability == "write" - with pytest.raises(HolderProofInvalid, match="already been used"): - _handle(req, seen_proofs=seen) - - -def test_without_a_cache_the_guarantee_is_only_the_window() -> None: - """Stated as a test so the weaker mode is a choice rather than a surprise.""" - chain, keys = _chain() - req = proved_request(chain, keys[-1], "write", "r0") - assert _handle(req).granted_capability == "write" - assert _handle(req).granted_capability == "write" # replayed, and accepted - - -def test_the_cache_only_remembers_proofs_that_verified() -> None: - """Recording before verifying would let anyone poison it. - - An attacker who could insert a signature they had not proved would be able to - lock the real delegate out of its own proof, so nothing enters the cache until - it has verified under the leaf subject. - """ - chain, keys = _chain() - seen = ProofReplayCache() - challenge = challenge_mod.issue_challenge(TEST_SECRET) - good = proved_request(chain, keys[-1], "write", "r0", challenge=challenge) - - # Mallory presents the same signature under a mismatched capability, so the - # signature check fails. Nothing should be remembered. - with pytest.raises(HolderProofInvalid): - _handle( - PeerRequest( - chain=chain, - requested_capability="read", - record_id="r0", - holder_proof=good.holder_proof, - ), - seen_proofs=seen, - ) - assert len(seen) == 0 - - # Bob's own call still works: his proof was never recorded by the failure. - assert _handle(good, seen_proofs=seen).granted_capability == "write" - - -def test_cache_entries_expire_with_their_challenge(monkeypatch: pytest.MonkeyPatch) -> None: - seen = ProofReplayCache(ttl_seconds=1) - assert seen.record("sig") is True - assert seen.record("sig") is False - real = time.monotonic - monkeypatch.setattr(time, "monotonic", lambda: real() + 5) - assert seen.record("sig") is True # forgotten, because its challenge is dead too - - -def test_cache_is_bounded_and_says_what_that_costs() -> None: - """Past capacity the oldest goes, so a flood degrades to the window.""" - seen = ProofReplayCache(max_entries=4) - for i in range(20): - assert seen.record(f"sig-{i}") is True - assert len(seen) <= 4 - assert seen.record("sig-0") is True # evicted, so replayable again - assert seen.record("sig-19") is False # still remembered - - -def test_a_node_remembers_proofs_by_default() -> None: - """The default posture, over the transport, not just the handler.""" - chain, keys = _chain() - node = PeerNode(POLICY, trusted_root_issuers={chain[0].issuer}) - assert node.seen_proofs is not None - message = a2a_adapter.attach_ca2a_metadata( - {}, - PeerRequest( - chain=chain, - requested_capability="write", - record_id="r0", - holder_proof=build_holder_proof( - keys[-1], - chain[-1], - audience=node.channel_public_key, - challenge=node.issue_challenge(), - requested_capability="write", - record_id="r0", - ), - ), - ) - assert node.handle(message).granted_capability == "write" - with pytest.raises(HolderProofInvalid, match="already been used"): - node.handle(message) - - -def test_a_node_can_opt_out_of_remembering() -> None: - """For a multi-instance deployment that shares no state.""" - node = PeerNode(POLICY, seen_proofs=None) - assert node.seen_proofs is None - - # -------------------------------------------------------------------------- # Fail-closed wiring # -------------------------------------------------------------------------- @@ -483,6 +466,7 @@ def test_replay_over_http_is_refused() -> None: record_id="m2", sealed_payload=None, caller_channel_key=None, + parent_record_hash=None, ) status, body = client._post_json( f"{base}{server.TASK_PATH}",