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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 4 additions & 6 deletions docs/spec/profile.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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

Expand Down
8 changes: 7 additions & 1 deletion docs/spec/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
138 changes: 41 additions & 97 deletions src/ca2a_runtime/delegation/holder.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,25 +31,33 @@
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
statement: the delegate signed for a call from
*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`` --
Expand All @@ -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

Expand All @@ -72,87 +77,23 @@
)

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

#: Domain separator. A signature made for a holder proof must never be
#: 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,
Expand All @@ -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,
Expand All @@ -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,
}


Expand Down Expand Up @@ -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:
Expand All @@ -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())

Expand All @@ -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)
Expand All @@ -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))
Expand All @@ -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",
)
Loading
Loading