Repository navigation
Open
Labels
Description
Spec
- Spec:
docs/specs/2026-04-24-epoch-key-rotation.md - Merged via PR spec: epoch-driven channel key rotation for PCS #220.
Summary
Introduce epoch-driven channel key rotation so that compromise of a ChannelKey expires the next time membership changes (kick, role/permission revoke, qualifying AssignRole, or explicit admin rotation). A new RotateChannelKeyV2 EventKind carries an explicit epoch: u32 and a trigger: Option<EventHash>; the state machine derives epoch_key[N+1] from epoch_key[N] || triggering_event.hash via HKDF-SHA256, rotates the per-channel TopicId to epoch_key_id, and enforces per-recipient membership validation. This closes the latent post-compromise security gap before message encryption ships in production paths.
Build phases
- Phase 0 — Crypto primitives. Add HKDF-Extract+Expand epoch derivation in
willow-cryptowith versionedsalt = b\"willow-crypto/v1/epoch/salt\"andinfoconstantswillow-crypto/v1/epoch/{key,id}alongside existingHKDF_*_DOMAINconstants. Produceepoch_key[N+1](32B) andepoch_key_id(16B). Add unit tests with HKDF spec vectors. - Phase 1 —
RotateChannelKeyV2EventKind. Add the new variant toEventKindincrates/state/src/event.rswith fields{ channel_id: String, epoch: u32, trigger: Option<EventHash>, encrypted_keys: Vec<(EndpointId, Vec<u8>)> }. Keep legacyRotateChannelKeyverbatim so persisted history bincode-deserializes byte-identically. Add bincode round-trip tests for both variants. - Phase 2 — State field + permission gate. Add
channel_epochs: BTreeMap<String, u32>toServerState(parallel tochannel_keys). Wirerequired_permission()forRotateChannelKeyV2toManageChannelsincrates/state/src/materialize.rs. - Phase 3 —
apply_eventforRotateChannelKeyV2. New arm enforcing:epoch == prev + 1(or0for genesis), trigger is instate.applied_eventsand of an admissible kind (per the trigger table),Propose { KickMember }triggers must be ratified (removed fromstate.pending_proposals), kicked/revoked peer excluded fromencrypted_keys, every recipient in post-state member set,trigger = Noneonly allowed for genesis or admin-authored out-of-band rotation. LegacyRotateChannelKeykeeps applying as opaque epoch-0 seed without advancingepoch_number. - Phase 4 — Governance / vote-trigger identity. Validate that vote-driven rotations carry the
Proposehash (never a specificVote). Cover the threshold/reevaluate_all_proposalsand owner-override paths. - Phase 5 — Hold-and-defer for out-of-order rotations. Per-channel pending-rotation queue; re-validate every time
state.applied_eventsgrows; configurable timeout (default 5 min wall-clock) drops the pending rotation; client surfaces a warning and re-authors a fresh rotation. - Phase 6 — Producer in
willow-client. Wiregenerate_channel_key()intoClient::create_server/Client::create_channel(genesisRotateChannelKeyV2withepoch = 0,trigger = None). Distribute via the invite flow (generate_invite/accept_invite). Auto-emit follow-upRotateChannelKeyV2after each membership-changing event (kick ratification,RevokePermission { SendMessages | SyncProvider }, qualifyingAssignRole,GrantPermission { SendMessages }). - Phase 7 — Consumer /
SealedContentintegration. Index decryption by(channel_id: String, key_epoch: u32) -> EpochKey(state-sideStringchannel id, NOT messaging-layerChannelId(Uuid)). Confirmseal_content_with_counterwrites the active epoch andopen_content_boundedreads it. - Phase 8 — TopicId rotation. Update
make_topic/topic_idpath to produceblake3(b\"willow-topic-v1\" || channel_id_bytes || epoch_key_id). Members atomically subscribe to the new topic on each epoch event; old topic kept briefly for in-flight messages then abandoned. - Phase 9 — Client warnings. Wall-clock-driven "membership change without rotation past timeout" warning (since
timestamp_hint_msis display-only, the warning lives in the client). Surface in the UI. - Phase 10 — Tests. Unit (HKDF vectors,
epoch_key_idstability), state (every trigger-table row, non-admissible triggers rejected, non-member recipient rejected, hold-and-defer + timeout drop, unratified-Proposerejected,epochmonotonicity, legacyRotateChannelKeystill applies, legacy + genesis V2 acceptance), wire (bincode round-trip both variants,Event::verifyfor legacy persisted events), integration (kick: kicked peer decrypts pre-kick but not post-kick; join-and-catch-up: new member decrypts post-join only), browser (warning surfaces when membership change is unaccompanied by rotation past timeout). - Phase 11 — Docs. Update CLAUDE.md "Adding a new EventKind" notes if needed; document the trigger table and the post-join confidentiality default.
Acceptance criteria
RotateChannelKeyV2EventKind exists, bincode round-trips, and validates per the spec's trigger table.- Legacy
EventKind::RotateChannelKeystill bincode-deserializes byte-identically andEvent::verify()returns true on historical events. ServerStatecarries achannel_epochs: BTreeMap<String, u32>field;apply_eventenforcesepoch == prev + 1(or0for genesis).- Vote-driven rotation triggers MUST be the
Proposehash; rotations referencing aPropose { KickMember }still instate.pending_proposalsare rejected. RotateChannelKeyV2withencrypted_keysfor a non-member peer is rejected; the kicked / revoked peer is excluded fromencrypted_keys.- HKDF derivation uses
salt = b\"willow-crypto/v1/epoch/salt\",info = b\"willow-crypto/v1/epoch/key\"(32B) andb\"willow-crypto/v1/epoch/id\"(16B), SHA-256 throughout. TopicId(channel, epoch) = blake3(b\"willow-topic-v1\" || channel_id_bytes || epoch_key_id); non-members cannot predict future topic IDs.SealedContent.key_epochis set authoritatively by senders and read by receivers via aBTreeMap<(String, u32), EpochKey>keyed on the state-side channelStringid.- Out-of-order rotations are held in a per-channel pending queue, re-validated on each
applied_eventsgrowth, and dropped past the (configurable, default 5 min) wall-clock timeout with a client warning. willow-clientemits a genesisRotateChannelKeyV2at channel creation and a follow-upRotateChannelKeyV2after every membership-changing event listed in the trigger table.- Client surfaces a warning when a membership event is applied without a follow-up rotation past the configurable timeout.
- New members receive
epoch_key[N+1]only and cannot decrypt epochs0..=N(default policy). - Integration test: a kicked peer can decrypt their pre-kick ciphertext but not post-kick ciphertext, even when retaining
epoch_key[N]. just checkpasses with zero warnings (fmt + clippy + test + WASM).
Out of scope
- Full forward secrecy of in-flight messages (would require a double-ratchet).
- Post-quantum confidentiality (X25519 only).
- IP-level / timing privacy (transport concern).
- Identity-key vs signing-key separation (recommended for a follow-up
RegisterSessionKeyspec; this spec does not split them). - DM (seal + gift-wrap) forward / post-compromise security — DMs do not use channel keys; tracked under a separate spec.
- Negentropy history sync changes — rotation events are normal DAG entries.
- Unifying the state-side
Stringchannel id with the messaging-layerChannelId(Uuid). - An
opt-inShareHistoricalKeys` channel setting for granting pre-join history access (left as an open question).
Open questions
- Past-message access policy. Default is "new members cannot decrypt pre-join." Add an opt-in
ShareHistoricalKeyschannel flag, or defer entirely? - Identity vs signing key separation. Land the split now, or in a follow-up? Sooner = less churn but touches
willow-identityand every signing path. - Derivation input.
prev_key || trigger.hash(chosen) vsprev_key || server_state_hash_after_trigger. Latter commits to more context but may diverge during DAG merge. - Retention of old epoch keys. Needed for history replay and late-arriving messages; deleting them is what actually delivers forward secrecy. Who decides the TTL, and is it per-client?
- Rotation storm. A rapid sequence of kicks produces a rotation per kick. Batch (coalesce within a short window), or accept the overhead for clarity?
Activity