diff --git a/.changeset/book-join-key-rule.md b/.changeset/book-join-key-rule.md new file mode 100644 index 0000000..d206e9a --- /dev/null +++ b/.changeset/book-join-key-rule.md @@ -0,0 +1,4 @@ +--- +--- + +Book-only: specify signing a joined group with its KeyPackage key, and note the deployed join anomaly; nothing ships. diff --git a/book/src/group-rules.md b/book/src/group-rules.md index a44546c..6648700 100644 --- a/book/src/group-rules.md +++ b/book/src/group-rules.md @@ -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` diff --git a/book/src/protocol-flows.md b/book/src/protocol-flows.md index 7ca1274..2696350 100644 --- a/book/src/protocol-flows.md +++ b/book/src/protocol-flows.md @@ -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 @@ -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.) diff --git a/book/src/session-lifecycle.md b/book/src/session-lifecycle.md index ac3b742..3fdbfc5 100644 --- a/book/src/session-lifecycle.md +++ b/book/src/session-lifecycle.md @@ -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 @@ -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 @@ -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'`.