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
4 changes: 4 additions & 0 deletions .changeset/book-join-key-rule.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
---

Book-only: specify signing a joined group with its KeyPackage key, and note the deployed join anomaly; nothing ships.
6 changes: 5 additions & 1 deletion book/src/group-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,11 @@ sequence is driven by the classical ratchet itself:
fast-forward to an already-canonical credential; candidates are proposed and
canonicalized exclusively in the classical ratchet. A credential that a live PQ leaf
still presents stays admissible past window eviction until that leaf catches up; the
A.3 founding pins are one instance of this rule.
A.3 founding pins are one instance of this rule. Until a leaf moves, its owner signs
in that group with the key the leaf presents. A group joined from a KeyPackage (the
A.3 KP′) is signed with that KeyPackage's key, even if the owner has rotated since it
was minted. Moving one group's leaf never retires a key that another group's leaf
still presents.

Enforcement is the mls-rs `IdentityProvider` (`apq/src/authentication.rs`):
`valid_successor` implements same-id / authorized-step / catch-up; `validate_member`
Expand Down
4 changes: 2 additions & 2 deletions book/src/protocol-flows.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ In its place we have two PQ operations:

One round re-keys ONE group; the turn alternation brings the other group’s round next. The large updatePath commit happens in isolation on the PQ group, otherwise we block the classical ratchet on transmitting it — only the small pathless ack rides the classical staple.

**Who opens a round — the session, not the host.** The host never selects or opens A.4/A.5; the session self-drives them. Whenever it is our turn, the PQ side-band is idle and not wedged, and both halves are live (post-A.3), the next `encrypt` opens the next round automatically: an **A.5 re-key** if either leaf in the PQ half of our receive group (the group our A.5 re-keys) lags, else an **A.4 ratchet**. A leaf *lags* when it presents a credential id other than its owner's *current* canonical (classically committed) id; a same-id key refresh is not a lag. If the lagging leaf is ours, the round is the credential catch-up: our `Upd'` announces our identity. If it is the peer's, the round is the reciprocal catch-up: the peer's responder `Commit'` carries theirs. Opening is send-driven and best-effort (a transient staging failure just retries on the next send), and the frame it stages rides that same send's re-staple. So the abstract "initiator (Alice) sends…" above is, concretely, *Alice's next ordinary message once the turn is hers*. One subtlety: a rotation that lands while an A.4 is staged, or while an A.5 `Upd'` is in flight, does not re-mint that round, and a responder whose own rotation staple has not yet applied answers with a `Commit'` that moves nothing. Either way the leaf still lags after the round and the next turn's trigger opens the catch-up — a race costs one extra round, never a stall. The deployed Rust engine deviates here after a one-sided rotation; see the shipped-anomaly note in [Session Lifecycle](./session-lifecycle.md).
**Who opens a round — the session, not the host.** The host never selects or opens A.4/A.5; the session self-drives them. Whenever it is our turn, the PQ side-band is idle and not wedged, and both halves are live (post-A.3), the next `encrypt` opens the next round automatically: an **A.5 re-key** if either leaf in the PQ half of our receive group (the group our A.5 re-keys) lags, else an **A.4 ratchet**. A leaf *lags* when it presents a credential id other than its owner's *current* canonical (classically committed) id; a same-id key refresh is not a lag. If the lagging leaf is ours, the round is the credential catch-up: our `Upd'` announces our identity. If it is the peer's, the round is the reciprocal catch-up: the peer's responder `Commit'` carries theirs. Opening is send-driven and best-effort (a transient staging failure just retries on the next send), and the frame it stages rides that same send's re-staple. So the abstract "initiator (Alice) sends…" above is, concretely, *Alice's next ordinary message once the turn is hers*. One subtlety: a rotation that lands while an A.4 is staged, or while an A.5 `Upd'` is in flight, does not re-mint that round, and a responder whose own rotation staple has not yet applied answers with a `Commit'` that moves nothing. Either way the leaf still lags after the round and the next turn's trigger opens the catch-up — a race costs one extra round, never a stall. The deployed Rust engine deviates here after a one-sided rotation; see [Shipped anomalies](./session-lifecycle.md#shipped-anomalies).

1. Session establishment
1. Bob posts an APQ keyPackage
Expand Down Expand Up @@ -139,7 +139,7 @@ Independently, we have an exchange of large PQ key messages, carried as dedicate
2. Alice and Bob take turns initiating PQ operations. Alice is first, and makes a variation of PQ re-keying to bootstrap Bob’s group:
1. (In place of a proposal) Alice sends a PQ keyPackage to Bob
2. (In place of a commit) Bob constructs the PQ half of his send group from it and replies with a Welcome (for that group)
3. Alice joins via the Welcome and closes the round with a bind, exactly as the PQ ratchet's — the only difference is where S comes from (a group exporter off the joined group's birth epoch rather than a KEM exchange). The bind rides her next classical commit as the staple; Bob takes the turn on applying it
3. Alice joins via the Welcome — signing in that group with KP′'s key, which her leaf there presents, until her own A.5 moves it — and closes the round with a bind, exactly as the PQ ratchet's — the only difference is where S comes from (a group exporter off the joined group's birth epoch rather than a KEM exchange). The bind rides her next classical commit as the staple; Bob takes the turn on applying it

(Bob’s dedicated principal is selected at session establishment, not here. Alice started with a principal she generated to talk to Bob’s invitation principal; Bob accepts under a principal dedicated to Alice — his send group is created directly under it, and Alice adopts it when she joins his group. The PQ bootstrap and re-key only carry already-canonical credentials onto the PQ leaves.)

Expand Down
91 changes: 69 additions & 22 deletions book/src/session-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ is not a lag. "A.4 begins immediately" is just the first send after the turn bec
ratchet then ping-pongs, turn-gated so the two sides never both open at once. Staging is
best-effort (a transient KEM/proposal failure simply retries on the next send) and the staged
frame rides that send's re-staple peek (`pq_pending_outbound`), so the host's role is
`.finishBootstrap` plus sending messages.
`.finishBootstrap` plus sending messages. A deployed host deviates here; see
[Shipped anomalies](#shipped-anomalies).

- **Bootstrap** (`0x13`/`0x15`, then a stapled bind) — stands up Group_B's deferred PQ half
off the critical path: Alice sends her PQ key package (`0x13`) — the one PRE-COMMITTED at
Expand Down Expand Up @@ -212,27 +213,8 @@ against the AS history window, and a credential that a live PQ leaf still presen
admissible past window eviction until that leaf catches up (see
[Group Rules](./group-rules.md), rule 4).

> **Shipped anomaly (deployed Rust engine).** The deployed engine opens an A.5 when its own
> *send*-PQ leaf lags, not when a leaf in its receive group lags, and it never opens the
> reciprocal A.5 for the peer. Its trigger reads its send-PQ leaf, but its own round can
> only move its receive-PQ leaf, so the trigger never clears itself. After a one-sided
> rotation, the rotated party's A.5 moves
> only its leaf in the peer's send group. The peer never opens the reciprocal round, so
> the rotated party's own send-PQ leaf keeps its pre-rotation credential. Because that
> leaf still lags, the rotated party opens another A.5 on every PQ turn it holds; after
> the first, each is a same-id key refresh. A conforming peer heals it: the deployed
> engine's responder `Commit'` does carry its current credential, so the reciprocal A.5
> completes the catch-up. Against a deployed peer, a conforming rotated party's own
> send-PQ leaf stays behind, because that peer never opens the reciprocal round, and that
> party must keep signing that group under the credential its leaf presents. For the same
> reason, a deployed born-dedicated acceptor never catches up its leaf in the initiator's
> send-PQ group: its own send-PQ leaf was minted at A.3 under the dedicated id, so it
> never lags, and its trigger never fires. The deployed AS also pins only the A.3 founding
> ids, so a leaf left behind for more than the history window can no longer catch up at
> all. Separately, a born-dedicated acceptor's catch-up Upds
> propose the identity the peer already treats as canonical (`proposing == sender`). A
> host that folds only offers where `proposing` differs from `sender` never commits one,
> so that recv-group leaf keeps presenting the invitation identity.
The deployed engine deviates from this catch-up; see
[Shipped anomalies](#shipped-anomalies).

For the common "dedicated agent per session" pattern, don't rotate at establishment
at all: pass the agent's id to `receive(…, new_client_id:)` and the session is born
Expand Down Expand Up @@ -270,3 +252,68 @@ table (contract 23) — so all four survive a restore. The token is opaque
to this crate — the caller picks the convention (Germ's adapter digests the envelope's
STABLE PREFIX — the app payload, else the bare welcome — so every pre-establishment
re-staple from the same initiator resolves to the same token).

## Shipped anomalies

This book specifies intended behavior. The deployed Rust engine, and the card host it
shipped in, deviate from it in six ways. Each item notes how it resolves: healed by a
conforming peer following the spec, healed only once the deployed party runs a conforming
engine, or needing an accommodation beyond the spec.

1. **Wrong trigger.** The deployed engine opens an A.5 when its own *send*-PQ leaf lags, not when a leaf
in its receive group lags, and it never opens the reciprocal A.5. Its own round can
only move its receive-PQ leaf, so the trigger never clears itself. After a one-sided
rotation, the rotated party therefore opens another A.5 on every PQ turn it holds
(after the first, each is a same-id key refresh), and its own send-PQ leaf keeps its
pre-rotation credential.
- *Resolution: healed by spec behavior.* The deployed responder `Commit'` does carry
its current credential, so a conforming peer's reciprocal A.5 completes the
catch-up and the loop stops. In the other direction, a conforming rotated party's
own send-PQ leaf stays behind against a deployed peer. That needs no accommodation:
rule 4 already has it keep signing that group with the key its leaf presents.
2. **Born-dedicated acceptor's PQ leaf.** A deployed born-dedicated acceptor never
catches up its leaf in the initiator's send-PQ group: its own send-PQ leaf was minted
at A.3 under the dedicated id, so its trigger never fires.
- *Resolution: healed once the acceptor runs a conforming engine,* whose own A.5
fires. The peer cannot heal it, since only the acceptor's own `Upd'` moves that
leaf.
3. **Born-dedicated acceptor's catch-up Upds.** They propose the identity the peer
already treats as canonical (`proposing == sender`). A host that folds only offers
where `proposing` differs from `sender` never commits one, so the acceptor's
recv-group leaf keeps presenting the invitation identity.
- *Resolution: healed by the peer's host.* A conforming engine marks such an offer
as a catch-up, which the host can approve without authorizing a new credential.
4. **History window.** The deployed AS pins only the A.3 founding ids.
- *Resolution: healed by spec behavior wherever a conforming engine validates the
move,* because it keeps any id a live PQ leaf presents admissible (rule 4). A
deployed validator still refuses a leaf left behind for longer than the history
window, and nothing heals that.
5. **Unchecked join.** The deployed engine's A.3 bind joins the peer's send-PQ group signing with its
*current* PQ key, not the KP′ key its leaf there presents (contrary to rule 4). After
a rotation before the bind, its A.5 `Upd'` in that group is mis-signed and always
rejected, and the presented key survives only as its own send-PQ group's signer.
When it later answers a peer's A.5, its responder `Commit'` replaces that signer,
and the leaf is orphaned for good: no copy of its key remains.
- *Resolution: needs an accommodation.* A conforming peer defers a reciprocal A.5
until the peer's own A.5 has succeeded, meaning the peer's leaf in our send-PQ
group is current. Against a conforming peer this costs at most one extra round.
A conforming engine that takes the party over drops its mis-signed parked `Upd'`
and re-proposes under the carried key, which heals it. Once the leaf is orphaned,
nothing heals it.
6. **The deployed card host never sends side-band frames.** It ships the initiator's KP′
inside an ordinary message, which the acceptor answers, standing up its send-PQ half
and parking `Welcome'`. But the host never sends that frame, so the initiator waits
for it indefinitely, and neither side becomes fully established. No A.4 or A.5 ever
runs, so the session keeps only the PQ protection its establishment seeded. The same
host does *receive* side-band frames and hands them to the session, and the deployed
anchor host co-sends each parked frame with the next message, so anchor sessions
complete A.3.
- *Resolution: healed once the acceptor's host carries side-band frames, as
[The PQ side-band](#the-pq-side-band) specifies and the anchor host already does.*
No protocol change is needed. An upgraded acceptor sends its parked `Welcome'`. The
deployed initiator binds, and its bind rides an ordinary frame, so A.3 completes
without the initiator upgrading. Every later A.4 or A.5 needs the deployed party to
send a side-band leg, so those rounds stay open, without error, until it upgrades.
How often a host re-sends its parked leg is host policy, but a host must never drop
it. A deployed acceptor cannot be healed from the initiator's side, because only it
holds the `Welcome'`.
Loading