Decisions

ADR-0012: Sender-key groups v1: encrypted headers, creator-managed membership, announce-until-acked

  • Status: Accepted
  • Date: 2026-07-13

Context

The crypto spec (04 §6) and architecture (03 §6) fix the v1 group model: per-member sender keys (a forward-ratcheting chain per sender per group), distributed over the existing pairwise Double Ratchet sessions, with each group message encrypted once and fanned out: one ciphertext, not N, which is what makes groups affordable over LoRa. The spec deliberately leaves the concrete wire format, membership semantics, and distribution reliability undefined. Implementing them forces five shape decisions:

  • What do intermediaries see? A naive sender-key header (key id + iteration in the clear) lets every relay and mesh bystander link all of one sender's group traffic across token rotations: exactly what sealed sender (04 §7) exists to prevent.
  • Who manages membership? Signal-style anarchic groups (anyone updates membership, clients converge) need conflict resolution machinery far beyond v1's "≤ 64 members" ambition.
  • How does a sender key reliably reach every member over carriers that lose whole envelopes (mesh, couriers)? A member missing one distribution message is deaf to that sender forever.
  • What happens on restore from backup? Chains are session-class state: exporting them has the same replay/fork hazards ADR-0011 rejected for ratchets.
  • How is a group message authenticated? The spec pins an "Ed25519-free MAC scheme": no signatures.

Decision

Chains (kult-crypto::group): per group each member holds a sending chain (key_id: 16 random bytes, chain_key: 32, iteration: u32); ck' = HKDF(ck, "KK-group-chain"), mk = HKDF(ck, "KK-group-msg"): the same shape as the pairwise symmetric ratchet, with the same delay-tolerance bounds (MAX_SKIP 1000, stored-skipped cap 2000 LRU, TTL 30 days) per receiving chain. There is no DH step: forward secrecy per sender comes from the chain, post-compromise security from rotation (below).

Wire format (EnvelopeKind::GroupMessage, body): version(1) ‖ enc_header(60) ‖ nonce(24) ‖ ct. The header, key_id(16) ‖ iteration(4 LE), is AEAD-sealed under a group header key K_hdr = HKDF(group_secret, "KK-group-hdr") known only to members, so intermediaries see uniformly random bytes; the payload is XChaCha20-Poly1305 under the message key with the group id, protocol version, and sealed header bound as associated data. Plaintexts ride the standard padding buckets. Delivery reuses the pairwise machinery unchanged: the one ciphertext is fanned out in per-member envelopes addressed by each pair's rotating delivery token, so relays, mailboxes, receipts, NACKs, and bridging all work on group traffic without knowing it is group traffic.

Membership is creator-managed: the member who created the group is the only one who may add, remove, or re-key it, and membership updates carry a monotonic generation counter so stale updates can never regress the roster. Every control message travels end-to-end encrypted over the pairwise ratchets (EnvelopeKind::GroupControl) in one self-contained shape: an announce carrying the group state (name, creator, roster with member identities, group secret, generation) plus the sender's current chain snapshot. Receivers honor the roster/secret/generation part only from the recorded creator; the sender-key part from any current member. One shape means invites, adds, removals, rotations, and redistributions are all the same message, and receiving any one of them is sufficient context to start decrypting its sender.

Reliability: each pending announce is tracked per (group, member) with the chain snapshot frozen at entitlement time (join, add, rotation): never the live chain, so a member served late can still read every message sent since they became entitled. Announces re-send on a slow end-to-end timer until the ordinary encrypted receipt acknowledges the envelope; group messages fanned out to a member whose session does not exist yet keep their per-member Queued state (with the ciphertext retained) and go out when the session appears. Group envelopes whose sender chain is not yet known stash in the existing pending store: "announce still in flight" and "handshake still in flight" are the same situation and get the same cure.

Rotation (fresh chain, new key_id, announces to the full roster): triggered by member removal or leave (spec: remaining members rotate), by a message-count threshold (PCS), by restore from backup, and (as a redistribution of the current chain, not a rotation) whenever a pairwise session with a co-member is re-established (their device may have restored and lost every receiving chain). On removal the creator also mints a fresh group secret; the previous one is kept for header-decrypting in-flight traffic, one generation deep.

Backup (introduced in format KKR2; current KKR4 and older KKR1/KKR2/KKR3 files restore): carries each group's identity (id, name, creator, roster, secret, generation) and the group message history, but never chains, mirroring ADR-0011: a restored node mints a fresh sending chain and announces it, and co-members redistribute theirs over the re-handshaken sessions.

Alternatives considered

  • Cleartext key id + iteration in the group header (Signal's SenderKeyMessage shape): rejected: Signal wraps its group messages in per-recipient sealed sender, so its header is never intermediary-visible; ours would be. One static id would undo the daily token rotation for group traffic.
  • Per-member re-encryption of group messages (no shared ciphertext): rejected by the spec: N ciphertexts is exactly what sender keys exist to avoid on airtime-budgeted links.
  • A shared group delivery token so one mesh broadcast serves all members: rejected for v1: collect-and-delete mailboxes break (the first collector drains everyone's copy; recipient-scoped tokens are ADR-0007's whole point). Worth revisiting as a mesh-only optimization once the HIL bench can measure it.
  • Signed sender keys (Ed25519 per chain, Signal-style): rejected: the spec pins the Ed25519-free construction; within a ≤ 64-member group, members are trusted not to forge each other, and the omission preserves deniability. The consequence is stated plainly below.
  • Anarchic membership with convergence (any member updates the roster): rejected for v1: concurrent adds/removes need causal ordering or CRDT semantics; a single writer plus a generation counter is verifiably monotone. MLS (RFC 9420) is the documented successor for large groups.
  • Exporting chains in backups: rejected for the same reasons ADR-0011 refused to export ratchets: restored chain state forks the moment either copy advances.

Consequences

  • Any group member can forge messages as any other member of that group (they hold the same chain keys). This is the documented trade of the signature-free design: group authenticity is membership-level, not member-level. Pairwise conversations are unaffected.
  • Sender keys require pairwise reachability: two members who share a group but no prekey path (no bundle, no DHT, no common carrier) cannot read each other until one appears. The creator always has bundles for everyone (adding requires a stored contact), so the roster's identities travel in the announce and the DHT resolves the rest where internet exists; on pure sneakernet the members swap bundles the same way they met the creator. No relaying re-encryption is possible or attempted.
  • A fan-out burst is linkable as a burst by an observer who sees several envelopes with identical bodies; sender and recipients stay sealed. The per-member-re-encryption alternative that would hide this was rejected above; the residual risk joins the transport-metadata table (02 §6).
  • The removed-member window is honest: whoever held the group secret can header-decrypt (but never read) traffic sealed under it until the creator's re-key propagates; payload keys rotate immediately with the chains.
  • The store's outbound queue gains group routing context, and KKR1 backups restore without groups: both pre-release formats, both versioned.
Edit this page on GitHub ↗