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
140 changes: 140 additions & 0 deletions docs/spec/mutual-attestation.md
Original file line number Diff line number Diff line change
@@ -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.
93 changes: 93 additions & 0 deletions src/ca2a_runtime/challenge.py
Original file line number Diff line number Diff line change
@@ -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.<expiry>.<random>.<mac>`` 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>.<random>.<mac>",
)
_, 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"
),
)
108 changes: 108 additions & 0 deletions tests/test_challenge.py
Original file line number Diff line number Diff line change
@@ -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)