diff --git a/docs/spec/mutual-attestation.md b/docs/spec/mutual-attestation.md new file mode 100644 index 0000000..923b5be --- /dev/null +++ b/docs/spec/mutual-attestation.md @@ -0,0 +1,140 @@ +# Mutual Attestation + +--- +Status: Proposal +Written: 2026-08-09 +Stability: Unstable +--- + +## What is one-directional, and what is not + +The primitives are symmetric. Either peer can bind a channel key into a report +(`attest_channel`) and either can appraise one (`verify_offer`); the +`claim6-cross-operator-attestation` experiment drives both directions and is why +[attestation.md](attestation.md) says the design composes into mutual attestation. + +**The reference transport is what is one-directional.** In `ca2a_runtime.transport`: + +``` +caller callee + │ GET /channel?nonce=N_caller ───────▶ + │ ◀─────────── ChannelOffer bound to N_caller + │ appraise, seal payload to callee key + │ POST /task ─────────────────────────▶ + │ verify delegation chain, act +``` + +The callee verifies the caller's **delegation chain**, which is authorization: it +proves the caller holds a credential whose scope covers the request. It says +nothing about what the caller is running. A callee has no way to know whether the +peer sending it a task is an enclave or a laptop. + +## The change + +A callee-issued challenge, and a caller offer bound to it. + +``` +caller callee + │ GET /channel?nonce=N_caller ───────▶ + │ ◀── ChannelOffer(N_caller) + challenge C + │ appraise callee, seal payload + │ attest own channel key under C + │ POST /task + ChannelOffer(C) ──────▶ + │ appraise caller BEFORE acting + │ ◀────────────── response sealed to caller key +``` + +Three properties fall out, and each is a requirement rather than a consequence: + +**The challenge must come from the callee.** A caller that picks its own nonce +proves only that it can produce a report, not that it produced one for *this* +exchange. That is the same replay the caller's nonce already prevents in the other +direction. + +**The callee must appraise before it acts, not merely before it responds.** The +task payload is sealed to the callee's key, so the callee can read it the moment +it arrives. Appraising afterwards means an unattested caller has already had its +work done. This ordering is the whole value of the change and needs a test that +fails if the calls are swapped. + +**The caller's key is the vehicle, not the payoff.** Binding it into a report +under the callee's challenge is what makes the caller's measurement live rather +than replayed. The callee learns what the caller is running, and that is the +property. An earlier draft of this document claimed the key would be ceremonial +unless the response were sealed to it; see the withdrawn decision below for why +that was wrong in this protocol. + +## Decisions taken 2026-08-09 + +1. **Stateless HMAC challenge** (option B below). Works across instances with no + storage, and the guarantee it gives is at-most-once-per-window rather than + exactly-once. That weaker property is stated here rather than left implied. +2. **Record the outcome, requirement configurable.** A callee does not demand + attestation by default and can be configured to. +3. ~~The response is sealed to the caller's attested key.~~ **Withdrawn on + 2026-08-09, before implementation.** The argument for it was that an appraised + key which is never used is ceremony. That was wrong on both halves. + + There is no confidential response to seal. `serialize_peer_result` never + echoes the opened payload, by design; the response carries the **provenance + record**, which exists to be chained by the caller and handed to a verifier. + Sealing it would produce a record only one enclave can read, which defeats the + point of portable evidence. It would encrypt the one artifact built to be + shareable. + + And the key was never ceremonial. It is the *vehicle*: binding it into a report + under the callee's challenge is what makes the caller's measurement live rather + than replayed. The callee learns what the caller is running, which is the + property mutual attestation exists for. The key does its job at appraisal time + whether or not anything is later encrypted to it. + + If a genuinely confidential response is ever added, sealing *that* to the + caller's key is the right move. Encrypting the provenance record is not. + +## The state problem + +A challenge is worth nothing unless it is single-use and expiring, and the +reference server currently keeps no state at all. + +**Option A: a challenge store.** Issue random challenges, remember them, delete on +use, expire on a timer. Straightforward, and it makes the server stateful — which +matters for anyone running more than one instance, because a challenge issued by +one is unknown to the next. + +**Option B: a stateless challenge.** `HMAC(server_secret, timestamp || random)`, +verified by recomputation. No storage, works across instances, and single-use is +*not* achievable without state: the same challenge replays until it expires. The +window is a parameter rather than zero. + +Neither is free and the difference is real: A gives exactly-once within one +process, B gives at-most-once-per-window across many. + +## Posture when the caller will not attest + +Most callers, today, cannot: cA2A is alpha and the ecosystem is two peers we run. +A callee that refuses unattested callers by default is a callee nobody can talk +to; one that accepts them silently has added a field nobody reads. + +The shape used everywhere else in this stack applies here: record the outcome, +make the requirement configurable, and never let absence look like success. A +callee should be able to say "hardware or nothing" and should not say it by +default. + +## What this still does not give you + +**It is not simultaneous.** The caller appraises the callee, then the callee +appraises the caller. There is an instant where the caller has committed a sealed +payload to a peer it has verified, and the callee has not yet verified it. A truly +simultaneous exchange needs a commitment step neither side can back out of, which +is a larger protocol than this. + +**It does not make either peer trustworthy.** It establishes what each side is +running. A correctly attested enclave can still be running a program that behaves +badly, and the delegation chain remains the thing that says what it is allowed to +ask for. + +**It is not validated on hardware in both directions.** The cross-operator run +recorded in [hardware-validation.md](../hardware-validation.md) had the caller +appraise a real TDX quote and the callee appraise nothing. Making the protocol +mutual does not make that run mutual; it makes a mutual run possible, and both +peers in it were driven by one operator's harness. diff --git a/src/ca2a_runtime/challenge.py b/src/ca2a_runtime/challenge.py new file mode 100644 index 0000000..5743c8d --- /dev/null +++ b/src/ca2a_runtime/challenge.py @@ -0,0 +1,93 @@ +"""Callee-issued challenges for mutual attestation. + +A caller that picks its own nonce proves it can produce an attestation report, +not that it produced one for *this* exchange. So the callee issues the nonce the +caller's report must bind. See ``docs/spec/mutual-attestation.md``. + +**Stateless, and the guarantee is weaker than a store's.** A challenge is +``v1...`` where the MAC is HMAC-SHA256 over the first three +parts under a server secret, so any instance can verify what any other issued and +nothing has to be remembered. The cost is that single-use is unachievable without +state: the same challenge replays until it expires. + +That makes the property **at-most-once-per-window**, not exactly-once, and the +window is the TTL. Sixty seconds is the default because it is long enough for a +handshake over a slow link and short enough that a captured challenge is worth +little. Deployments that need exactly-once want a challenge store, which is the +option this one was chosen over and which remains the right answer for a peer +that only ever runs as one process. + +The secret is per-process by default. Restarting the callee invalidates +outstanding challenges, which is correct: a restarted enclave is a different +enclave, and a challenge it never issued should not verify. +""" + +from __future__ import annotations + +import hmac +import secrets +import time +from hashlib import sha256 + +from ca2a_runtime.errors import AttestationFailed + +__all__ = ["DEFAULT_TTL_SECONDS", "generate_secret", "issue_challenge", "verify_challenge"] + +DEFAULT_TTL_SECONDS = 60 +_PREFIX = "v1" +_RANDOM_BYTES = 16 + + +def generate_secret() -> bytes: + """A per-process challenge secret. Not persisted, deliberately.""" + return secrets.token_bytes(32) + + +def _mac(secret: bytes, expiry: int, rand: str) -> str: + return hmac.new(secret, f"{_PREFIX}.{expiry}.{rand}".encode(), sha256).hexdigest() + + +def issue_challenge(secret: bytes, *, ttl_seconds: int = DEFAULT_TTL_SECONDS) -> str: + """Issue a challenge that expires ``ttl_seconds`` from now.""" + if ttl_seconds <= 0: + raise ValueError("ttl_seconds must be positive; a challenge that never validates is not a challenge") + expiry = int(time.time()) + ttl_seconds + rand = secrets.token_hex(_RANDOM_BYTES) + return f"{_PREFIX}.{expiry}.{rand}.{_mac(secret, expiry, rand)}" + + +def verify_challenge(secret: bytes, challenge: str, *, now: int | None = None) -> None: + """Raise :class:`AttestationFailed` unless *challenge* is one we issued and is unexpired. + + Order matters: the MAC is checked before the expiry. Reading a timestamp out + of an unauthenticated string and acting on it means trusting an attacker's + arithmetic, and reporting "expired" for a forged challenge tells the sender + their forgery was well-formed. + """ + parts = (challenge or "").split(".") + if len(parts) != 4 or parts[0] != _PREFIX: + raise AttestationFailed( + "challenge is malformed", + detail="expected v1...", + ) + _, expiry_str, rand, mac = parts + try: + expiry = int(expiry_str) + except ValueError as exc: + raise AttestationFailed("challenge expiry is not an integer") from exc + + if not hmac.compare_digest(mac, _mac(secret, expiry, rand)): + raise AttestationFailed( + "challenge was not issued by this peer", + detail="the MAC does not verify under this peer's challenge secret", + ) + + current = int(time.time()) if now is None else now + if current >= expiry: + raise AttestationFailed( + "challenge has expired", + detail=( + "challenges are valid for a bounded window; this is a stateless " + "scheme, so the window is the only replay bound there is" + ), + ) diff --git a/tests/test_challenge.py b/tests/test_challenge.py new file mode 100644 index 0000000..2e64dab --- /dev/null +++ b/tests/test_challenge.py @@ -0,0 +1,108 @@ +"""Tests for callee-issued challenges. + +A challenge exists to stop a caller replaying an attestation report it made +earlier for someone else. So the tests are the ways a challenge could look valid +and not be: forged, expired, issued by a different peer, or reshaped so a field +an attacker controls gets read before anything authenticates it. +""" + +from __future__ import annotations + +import time + +import pytest + +from ca2a_runtime.challenge import ( + DEFAULT_TTL_SECONDS, + generate_secret, + issue_challenge, + verify_challenge, +) +from ca2a_runtime.errors import AttestationFailed + + +def test_round_trip() -> None: + secret = generate_secret() + verify_challenge(secret, issue_challenge(secret)) + + +def test_a_different_peer_secret_does_not_verify() -> None: + """A challenge is only worth something to the peer that issued it.""" + challenge = issue_challenge(generate_secret()) + with pytest.raises(AttestationFailed, match="not issued by this peer"): + verify_challenge(generate_secret(), challenge) + + +def test_expiry_is_enforced() -> None: + secret = generate_secret() + challenge = issue_challenge(secret, ttl_seconds=1) + with pytest.raises(AttestationFailed, match="expired"): + verify_challenge(secret, challenge, now=int(time.time()) + 5) + + +def test_valid_until_the_moment_it_expires() -> None: + secret = generate_secret() + expiry = int(issue_challenge(secret, ttl_seconds=30).split(".")[1]) + challenge = issue_challenge(secret, ttl_seconds=30) + verify_challenge(secret, challenge, now=expiry - 1) + + +def test_tampering_with_the_expiry_is_caught() -> None: + """The reason the MAC covers the timestamp. + + Without it, extending a captured challenge is a one-character edit. + """ + secret = generate_secret() + _, expiry, rand, mac = issue_challenge(secret, ttl_seconds=1).split(".") + forged = f"v1.{int(expiry) + 86400}.{rand}.{mac}" + with pytest.raises(AttestationFailed, match="not issued by this peer"): + verify_challenge(secret, forged) + + +def test_a_forged_challenge_is_reported_as_forged_not_expired() -> None: + """Order matters: authenticate before reading the timestamp. + + Reporting "expired" for a forgery tells the sender their forgery was + well-formed, and acting on an unauthenticated timestamp means trusting an + attacker's arithmetic. + """ + secret = generate_secret() + forged = f"v1.{int(time.time()) - 10}.deadbeef.{'0' * 64}" + with pytest.raises(AttestationFailed, match="not issued by this peer"): + verify_challenge(secret, forged) + + +def test_challenges_are_unique() -> None: + secret = generate_secret() + assert len({issue_challenge(secret) for _ in range(50)}) == 50 + + +@pytest.mark.parametrize( + "bad", + ["", "nonsense", "v1.123", "v1.123.abc", "v2.123.abc.def", "v1.notanint.abc.def"], +) +def test_malformed_challenges_are_refused(bad: str) -> None: + with pytest.raises(AttestationFailed): + verify_challenge(generate_secret(), bad) + + +def test_zero_ttl_is_refused_at_issue_time() -> None: + """A challenge that never validates is a bug that would present as flaky.""" + with pytest.raises(ValueError, match="not a challenge"): + issue_challenge(generate_secret(), ttl_seconds=0) + + +def test_default_window_is_short() -> None: + """Stateless means the window is the only replay bound there is.""" + assert DEFAULT_TTL_SECONDS <= 300 + + +def test_a_restarted_peer_invalidates_outstanding_challenges() -> None: + """A restarted enclave is a different enclave. + + The secret is per-process and not persisted, so this is the behaviour rather + than an accident of it. + """ + challenge = issue_challenge(generate_secret()) + with pytest.raises(AttestationFailed): + verify_challenge(generate_secret(), challenge)