spec: epoch-driven channel key rotation for PCS#220
Conversation
| | `EventKind` variant | Rotates? | Why | | ||
| |--------------------------------------|----------|------------------------------------------------------| | ||
| | `CreateChannel` | Genesis | Establishes epoch 0. | | ||
| | `RotateChannelKey` | Yes | Explicit rotation — today's manual path. | | ||
| | `ProposedAction::KickMember` → apply | Yes | Kicked member must lose future read access. | | ||
| | `RevokePermission { SendMessages }` | Yes | Revoked writer must also lose read on same rotation. | | ||
| | `RevokePermission { SyncProvider }` | Yes | Former provider must not silently keep decrypting. | | ||
| | `AssignRole` (adds member to channel)| Yes | Let rotation double as the join-key handoff. | | ||
| | `AssignRole` (no-op / same role) | No | Skip to avoid chatter. | | ||
| | `GrantPermission { SendMessages }` | Yes | Mirror of revoke. | | ||
| | `DeleteChannel` | N/A | No further epochs. | | ||
| | `Message`, `Edit`, `Reaction`, … | No | Content events never rotate. | | ||
| | `SetProfile`, pins, renames | No | Nothing membership-sensitive. | | ||
|
|
There was a problem hiding this comment.
This table doesn't match the real EventKind at crates/state/src/event.rs:69-174.
Missing / wrong:
- No
Administratorpermission exists. The permission set atevent.rs:20-33is{SyncProvider, ManageChannels, ManageRoles, SendMessages, CreateInvite}. Admin status isProposedAction::GrantAdmin/RevokeAdminvia thePropose/Votepath. Both should rotate — losing admin is a significant authority change. Votethat passes threshold is the actual rotation trigger forKickMember,GrantAdmin,RevokeAdmin— not theProposeevent. Thetriggerhash needs to be the specificVotewhose application caused auto-apply. Spell this out; otherwise the derivation input is ambiguous when multiple votes race.SetPermission(on a role) is missing. ASetPermission { permission: "SendMessages", granted: false }on a widely-assigned role is effectively a bulk revoke. If per-peer revoke rotates, this must too — otherwise there's a trivial bypass.DeleteRoleis missing. Same issue: deleting a role that grantsSendMessagessilently revokes from everyone assigned to it.AssignRole"adds member to channel" is not a thing in the current state model.Channel(types.rs:27-39) has no member list or role ACL — permissions are server-wide. Clarify the rule as "AssignRole that changes the peer's effective permission set" or call out the required state-model change.
Generated by Claude Code
| ```text | ||
| epoch_key[0] = CSPRNG at channel creation (existing CreateChannel path) | ||
| epoch_key[N+1] = HKDF-Extract( | ||
| salt = b"willow-epoch-v1", | ||
| ikm = epoch_key[N] || triggering_event.hash | ||
| ) // 32 bytes | ||
| epoch_key_id = HKDF-Expand( | ||
| prk = epoch_key[N+1], | ||
| info = b"willow-epoch-id-v1", | ||
| L = 16 | ||
| ) // 16 bytes | ||
| ``` | ||
|
|
||
| - SHA-256 is the HKDF hash throughout — matches Willow's existing | ||
| `KeyRatchet` in `crates/crypto/src/lib.rs:91–174`. | ||
| - `triggering_event.hash` is sufficient — the parent DAG context is | ||
| already folded in because the event hash commits to `prev` and | ||
| `deps`. Folding the full state hash in addition was considered and | ||
| rejected: it forces ordering determinism inside the derivation, and | ||
| HLC/DAG merge can momentarily disagree on state hash even when the | ||
| set of events is identical. |
There was a problem hiding this comment.
The HKDF-Extract construction is sound and the argument for trigger.hash over server_state_hash checks out — SignableContent at event.rs:206-215 commits to author, seq, prev, deps, and kind, so the event hash is a compact witness for the per-author chain plus its seen-ancestors. Good call.
Two specifics:
- Type width mismatch.
epoch: u64here, butSealedContent.key_epochatmessaging/src/lib.rs:166isu32, andKeyRatchet.epochatcrypto/src/lib.rs:105is alsou32. Pick one and thread it through the whole derivation — if you HKDF-expand withinfothat includes epoch bytes (the ratchet does), the width matters for the derived bytes being the same on both sides. - Commit
channel_idinto the derivation. Right now the HKDF-Extract input isprev_key || trigger.hash— the channel ID is only implicit via the previous-key chain. If someone ever initializes two channels with the same CSPRNG output (vanishingly unlikely but not impossible), derivations would collide. Cheap fix: addchannel_idto the salt or the ikm.salt = b"willow-epoch-v1:" || channel_idis clean.
Generated by Claude Code
|
|
||
| `crates/network/src/topics.rs` currently derives `TopicId` from the | ||
| server+channel string. That topic is stable for the channel's life, so | ||
| passive gossip observers can correlate traffic volume with membership. | ||
| Under this spec: | ||
|
|
||
| ```text | ||
| TopicId(channel, epoch) = blake3( | ||
| b"willow-topic-v1" | ||
| || channel_id_bytes | ||
| || epoch_key_id | ||
| ) | ||
| ``` | ||
|
|
||
| Using `epoch_key_id` (not `epoch_number`) means a non-member cannot | ||
| predict future topic IDs. Members transition topics on each epoch | ||
| event — they already have `epoch_key[N+1]`, so they know | ||
| `epoch_key_id[N+1]`, so they can subscribe to the new topic | ||
| atomically. The old topic stays alive briefly for in-flight messages | ||
| and is abandoned. |
There was a problem hiding this comment.
The "atomic transition" claim only holds for existing members. For a joining member there's a real race:
AssignRoleapplied → joining member sees the event via server-ops topic.RotateChannelKeyapplied → they decryptencrypted_keys[self], deriveepoch_key[N+1], computeepoch_key_id[N+1], compute the newTopicId.- Subscribe to the new topic.
Between step 2 and step 3, any message sent on the new topic is missed. iroh-gossip doesn't do reliable catch-up on subscribe. You need one of:
- Sender-side retransmit on membership-add — every sender holds a short ring buffer and re-broadcasts when they observe
AssignRole+RotateChannelKeylanding. Costly and non-obvious who "owns" it. SyncProvideroracle — storage worker sits on the new topic and responds to per-peer catch-up requests keyed by epoch. This is what spec: history sync completion signal (EOSE-equivalent) #214 (EOSE) is building toward; reference it.
Current text glosses over this. Pick a mechanism and specify the window bound.
Also worth noting: epoch_key_id is 128-bit, but a passive observer who obtains epoch_key[N-1] and then sees RotateChannelKey { trigger } can still derive epoch_key_id[N] and correlate. Rotation breaks correlation only for observers who never had any epoch key.
Generated by Claude Code
| 4. **Out-of-order handling.** A `RotateChannelKey` arriving before | ||
| its `trigger` event — hold it or reject? Willow's insert flow | ||
| tolerates missing deps; this spec should specify whether the | ||
| epoch transition is deferred until the trigger is applied. | ||
| 5. **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? | ||
| 6. **Rotation storm.** A rapid sequence of kicks produces a rotation | ||
| per kick. Do we batch — e.g., coalesce rotations within a short | ||
| window — or accept the overhead for clarity? |
There was a problem hiding this comment.
Open question #4 (out-of-order) needs a concrete answer in-spec, because the two naive choices are both wrong:
- Reject
RotateChannelKeyiftriggeris unknown violates Willow's insert flow —depsis advisory perevent.rs:195-197, well-formed signed events must be accepted. - "Defer until trigger applies" means
ServerStateneeds a pending-rotations queue andapply_eventbecomes stateful across calls, breaking the pure-function contract ofcrates/state/src/materialize.rs.
Cleaner answer: apply the rotation event to state immediately (increment epoch_number, store encrypted_keys), but gate activation of encryption on the trigger being present. Readers that see SealedContent.key_epoch == N+1 and don't yet have epoch_key[N+1] buffer the ciphertext. This keeps apply_event pure and makes the eventual-consistency story clean.
Open question #6 (rotation storm) similarly needs a concrete answer — a 500-member server losing 50 spammers produces ~25k X25519 wraps with the current per-event rule. Simplest fix: let trigger be Vec<EventHash> and derive as HKDF-Extract(salt, prev_key || sorted_hashes_concat). One rotation absorbs a burst, still deterministic.
Generated by Claude Code
intendednull
left a comment
There was a problem hiding this comment.
Round 2 review — comparative research against MLS / Signal / Matrix / WhatsApp
Round 1 dug into HKDF construction and the EventKind rotation table. This pass steps back and asks: is this design class right at all, or are we reinventing a pattern the messaging-crypto community has already wrung out the bugs of? Short answer: the spec sits in the same design slot as Matrix's megolm and WhatsApp's Sender Keys, and inherits their well-known problems. MLS (RFC 9420) was specifically built to retire this slot. We should at minimum understand why before shipping.
1. The spec is a sender-keys / "rotate-on-membership-change" design
Stripping away the Willow-specific framing, the spec proposes:
- A long-lived symmetric channel key.
- Membership-change events trigger derivation of a new key.
- The new key is delivered to each remaining member by individually X25519-wrapping it (
encrypted_keys: Vec<(EndpointId, Vec<u8>)>). - New joiners get only the current epoch's key.
This is structurally identical to:
- WhatsApp/Signal Sender Keys (SKDM) — a per-sender symmetric key distributed via pairwise channels, ratcheted forward, rotated on group changes. Forward-secure, but PCS only at the granularity of full re-keys. (WhatsUpp with Sender Keys, eprint 2023/1385)
- Matrix Megolm — a "room key" rotated on membership change, distributed pairwise via Olm sessions. (Megolm overview)
Both predate MLS standardization and have years of production data. We should learn from them rather than re-derive their failure modes.
2. O(n) wraps per rotation — exactly the problem TreeKEM solves
Round 1 flagged the per-rotation cost: 500 members × 50 rotations/day = ~25k X25519 wraps. The spec's encrypted_keys: Vec<(EndpointId, Vec<u8>)> is inherently O(members) per rotation.
RFC 9420's TreeKEM reduces this to O(log n):
"Encrypting to all but one member of the group requires only log(N) encryptions to subtrees, instead of the N-1 encryptions that would be needed to encrypt to each participant individually."
For a 500-member server, that's the difference between 499 wraps per kick and ~9. For 50 rotations/day that's 450 ops/day vs 25,000. This is not a micro-optimization — it's the entire reason MLS exists in the form it does. (RFC 9420 §5.4 Ratchet Tree)
If we stay with the flat encrypt-to-each-member approach, the spec should at least:
- Document the scaling ceiling explicitly (members × rotation rate × wrap cost).
- Suggest practical caps (e.g. "rotation is O(n), so this scheme is intended for ≤N-member channels").
- Address the "rotation storm" open question (#6) with concrete batching, not as a TBD.
3. MLS Welcome messages solve the new-member race the spec hand-waves
The spec's Joining section (lines 165–180) reads:
- Membership event applied at the DAG head.
- The author … emits the follow-up
RotateChannelKey…- The new member decrypts their entry …
Steps 1 and 2 are separate DAG events that "SHOULD" be emitted back-to-back (line 125–129). What if the author of step 1 crashes between 1 and 2? What if step 2 is censored by a malicious relay? What if the new member sees step 1 first and tries to subscribe to a topic they don't yet have the key for? The spec acknowledges this with a "client MUST surface a warning" — but a warning isn't a recovery.
MLS's Welcome message is the fix here:
The committer generates a Welcome message containing information allowing new members to "initialize their local state" and synchronize with the group's current epoch.
Crucially, the Welcome is cryptographically bound to the Commit that admitted the new member — they're a single atomic unit. The new member cannot be admitted without simultaneously receiving the key material to participate. This eliminates the "membership event without rotation" race entirely.
If we don't adopt MLS Welcome semantics, we should at minimum require that RotateChannelKey is structurally bundled with its triggering membership event — same author, same parent set, atomically applied — rather than two independent events linked by a trigger: Option<EventHash> field.
4. Matrix UTD is the warning siren we should be listening to
The spec's distribution model — "deliver a separate event with the new key, hope every member receives it" — is essentially how Matrix megolm distributes room keys. Matrix has had years of UTD ("Unable to Decrypt") production bugs from this exact pattern. Documented root causes: (Matrix Conf 2024 talk, neko.dev analysis)
- Sender logs out before key share completes → key never arrives.
- Device-list desync → keys sent to wrong device set.
- Olm session corruption between sender and one recipient → that recipient gets UTD permanently.
- Key share fails for one recipient but message still sends → partial UTD.
Map these onto Willow:
| Matrix failure | Willow analogue under this spec |
|---|---|
| Sender logs out before key share | Rotator goes offline before RotateChannelKey is gossiped to all peers |
| Device-list desync | Rotator's view of member_set differs from the post-state member set |
| Olm session corruption | A peer's X25519 wrap fails (bad ephemeral, corrupted ciphertext) |
| Partial UTD | Some peers receive RotateChannelKey, others don't, gossip eventual-consistency takes time |
Matrix's eventual mitigations (none of which the spec mentions):
- Server-side encrypted key backup — keys backed up under a passphrase-derived secret so a re-logged-in client can recover.
- Cross-signing / key verification — out-of-band trust establishment so key-share races are recoverable.
- Key request/forward protocol — recipients can ask other members for missing keys.
- Complement-Crypto test suite — explicit regression tests for "unhappy path" key delivery. (Matrix Conf 2024)
The spec needs at least #3 (a RequestEpochKey event a member can emit when they see an epoch they lack) and ideally a sketch of #1.
5. Why Signal/WhatsApp didn't adopt MLS — and what they accept
Signal's group design is pairwise Signal channels (with sender keys for fan-out). Their stated rationale (Signal blog):
"A zero-round-trip asynchronous-oriented protocol with low complexity."
The Sender Keys analysis paper (eprint 2023/1385) is blunt about the tradeoff:
"Sender Keys stands out for its relative simplicity, which reduces its potential attack surface … good performance in small to moderate-sized groups … up to 1024 parties in WhatsApp and Signal."
"MLS standardization only occurred recently, and MLS is yet to be widely deployed."
So Signal and WhatsApp consciously chose:
- Pro: simpler implementation, smaller attack surface, asynchronous-friendly, no centralized group-state ordering needed.
- Con: PCS only on full re-key, O(n) cost per rotation, no sublinear scaling.
- Cap: ~1024 members — beyond that the design doesn't hold up.
This is a legitimate engineering position, but it's a conscious product decision they document. The spec should make the equivalent decision explicitly: "We are choosing sender-keys-class, not MLS, because [DAG-native, no central group-state ordering, simpler]; in exchange we accept O(n) rotation cost, ~N-member ceiling, and no sub-tree PCS."
Without that statement, a future reader can't tell whether MLS was considered and rejected on merit, or simply not considered.
6. Willow's DAG context vs MLS's linear epoch ordering
One genuine reason MLS may not fit cleanly: MLS assumes a totally ordered sequence of Commits, with each Commit producing a single canonical epoch transition. Willow's event DAG explicitly tolerates concurrent membership events that merge. Two members concurrently kicking each other, or two AssignRole events at the same DAG height, don't have a single "next epoch" — they have a merge.
The spec acknowledges this implicitly by deriving from triggering_event.hash rather than state_hash (open question #3). But it doesn't grapple with: what happens when two membership events at the same DAG height each produce their own epoch? Do we get two parallel epoch chains and merge them? MLS-style protocols simply don't allow this — the Delivery Service serializes Commits.
This is the strongest argument for not adopting MLS wholesale. But it's also a brand-new research problem ("MLS over a DAG"), and the spec should either:
- Punt to a linear order at the channel level (channels have a single epoch chain even if the broader server DAG forks), or
- Specify the merge semantics for concurrent epoch transitions in detail.
Currently it does neither.
7. Recommendation
Three options, ranked by what I think the right call is:
Option A — Adopt MLS (RFC 9420) for channel encryption. Heaviest lift, but it's a vetted standard with formal security proofs, sub-linear scaling, atomic Welcome semantics, and active library ecosystem (openmls is mature Rust). The DAG-merge question would need a "channels are linearized" carveout. This is what NIP-EE/Marmot tried for Nostr and is what Wire / Webex / Cisco shipped.
Option B — Keep the sender-keys-class design but specify a key-recovery subspec NOW, before merging. Add:
RequestEpochKeyandProvideEpochKeyevents for missing-key recovery.- Atomic bundling of membership event + rotation (not separate DAG events).
- Explicit member-cap / rotation-rate guidance.
- A documented "we chose this over MLS because…" rationale.
- Test cases for the Matrix-style unhappy paths (sender goes offline mid-rotation, partial delivery, member-set desync).
Option C — Merge as-is and discover the UTDs in production. Not recommended. The Matrix project has spent ~7 years debugging this exact class of problems. We have their lessons available for free.
My read: Option B is the realistic path, but it requires expanding the spec significantly before code lands. Option A is correct on the technical merits but is a 6-month project, not a feature PR.
Either way, please add a "Comparison to prior art" section to the spec citing MLS, Sender Keys, and Megolm, and an explicit choice with rationale. That alone makes the design defensible to future reviewers.
Sources
- RFC 9420: The Messaging Layer Security Protocol
- Signal — Private Group Messaging
- Signal Sesame specification
- WhatsUpp with Sender Keys? — eprint 2023/1385
- Matrix Conference 2024 — "Unable to decrypt this message" (Kegan Dougal)
- Megolm internals — Sumner Evans
- Matrix UTD root-cause analysis — neko.dev
Generated by Claude Code
- Drop `provider_peer` from payload; receiver derives from verified envelope signer (forecloses MITM/relay-rewrite class). - Rename `epoch` -> `stream_generation` to disambiguate from the crypto-epoch in #220. - Switch from `broadcast_neighbors` to `broadcast` with receiver- side dedup on `(provider, stream_generation)`; no new transport primitives needed. - Soften NIP-01 over-claims; explicitly note the truncation-detection contract is deliberately stronger than NIP-01. - Fix `crates/relay/src/lib.rs:13` line citation; reference the `Trust model` section in module docs instead.
Apply review decisions to the relay capability document spec: - Promote signing to v1 MUST (inline signature, RFC 8785 JCS canonical bytes, signature field excluded from canonicalisation). - Specify dispatch surgery: explicit branch in dispatch_connection for /.well-known/willow plus OPTIONS preflight; reuse BOOTSTRAP_IO_TIMEOUT and MAX_CONCURRENT_BOOTSTRAP_CONNECTIONS; extend (not mirror) the handle_bootstrap_connection pattern. - Drop event_schema_range (no EVENT_SCHEMA_VERSION exists in willow-state); list as future work. - Resolve multi-tenant question: one shared doc per host, relay is topic-agnostic. - Soften operator-metadata leakage: version is coarse semver, software is project name, both MAY be omitted. - Two-tier caching by status: ok=300s, degraded/read_only=5s with must-revalidate. - Recommend WS clients also send Sec-WebSocket-Protocol; JSON is advisory pre-connect. - Fix port framing: relay binds one port multiplexing TCP+WS, not two. - Drop sync_provider_only (operator vibes without a concrete pre-handshake check). - Add Cross-spec coordination table pinning feature tags for #214, #216, #217, #218, #219, #220, #221. - Rewrite Open Questions to keep only genuinely-open items (paid-relay semantics, utilisation telemetry, relay discovery, feature registry). https://claude.ai/code/session_01XmbVXWnKTRVjPp9kmKRSBn
- Fix stale crate line citations (ChannelKey 91-112, KeyRatchet 135-208, encrypt_channel_key_for 388-422) shifted by HKDF domain separator block. - Correct epoch-0 origin: CreateChannel carries no key; key is generated client-side by willow_crypto::generate_channel_key() and distributed via invite / RotateChannelKey. - Reframe SealedContent.key_epoch as plumbed-but-zero (seal_content / open_content_bounded already read+write it; no production caller invokes seal_content yet). - Reframe "compromise leaks every message" as future risk — no production code currently produces Content::Encrypted. - Drop "today's manual path" claim for RotateChannelKey — no client API exposes it; only constructed in tests. - Reclassify KickMember as a ProposedAction (governance via Propose + threshold-satisfying Vote), not an EventKind. - Drop the bogus "HLC cap on the state machine" — willow-state has no HLC; reword the storage-worker mitigation around the client-side rotation-timeout warning and multi-provider state-hash agreement. - Mark the "reject RotateChannelKey to non-member recipients" check as a NEW validation, with an explicit pointer to the current unconditional insert in apply_mutation. - Define what makes an AssignRole "membership-changing" given there is no per-channel ACL surface today. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Correct relay-config description: IrohNetwork takes single Option<RelayUrl>; hardcoded URL lives in crates/web/src/app.rs (DEFAULT_RELAY_URL), not as a list in iroh.rs. - Drop "fall back to the bootstrap fleet" wording; there is no relay fleet today, only a single configured relay. - Clarify pkarr is a pluggable iroh service that Willow does NOT enable today (Endpoint::empty_builder()); enabling it is part of the proposal. - Quote the actual JoinToken shape from crates/client/src/ops.rs (inviter_peer_id is EndpointId, not String). - Add explicit NodeId vs EndpointId terminology note up front; switch field name to bootstrap_endpoint_ids. - Refer to GrantPermission as an EventKind variant with its real fields, not a "record". - Phrase PR #220 epoch-key encryption as forward-looking ("will be"), and acknowledge current cleartext gossip on _willow_server_ops. - Update Tests and Open questions to match renamed fields and corrected fallback semantics. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- epoch field narrowed to u32 to match SealedContent.key_epoch and KeyRatchet - Clarified RotateChannelKey is not yet emitted in production (no producer) - Tightened line cites for KeyRatchet and SealedContent Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- epoch / trigger annotated with #[serde(default)]; legacy semantics defined - Clarified genesis: first RotateChannelKey at epoch = 0 - HKDF derivation now Extract+Expand with domain separator (matches existing convention) - Broader test-caller list for generate_channel_key() - Topic derivation cite includes client/util.rs:55-58 - SealedContent line range and writer cite tightened - Propose/KickMember spelled out fully Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-hash trigger - round 5 - New EventKind::RotateChannelKeyV2 (bincode wire-compat: new variant, legacy kept opaque) - Rotation is a separate DAG event, not an apply_mutation side effect; trigger field carries Propose.hash - Vote-driven trigger identity pinned to Propose.hash (stable, race-free) - Out-of-order: hold rotation until trigger applied; timeout-reject fallback - Reframed "today" -> "under this spec" for generate_channel_key flow - HKDF salt convention noted as new (info convention matches existing) - Minor line-cite tightening + ChannelId type clarification Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Trigger re-eval cite: reevaluate_all_proposals at materialize.rs:242-258 - HKDF salt claim: "all None" -> "None or unkeyed advance label" - Legacy handler: checks author-is-member; only per-recipient loop is unchecked - Drop Rejected proposal state (doesn't exist); use "not yet ratified" - Minor cite tightening + AssignRole no-op-for-non-member note Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Part of a set of 8 specs drawing lessons from Nostr's protocol and ecosystem. Use this PR to discuss the design — not proposing implementation, only the spec.
What & why
Willow's current
ChannelKey(crates/crypto/src/lib.rs:57–78) is a long-term symmetric secret. A compromise leaks every past and future message until someone manually rotates. This is the NIP-04 failure mode at group scale.Nostr's trajectory — NIP-04/44/17 lack FS/PCS because the conversation key is deterministic
HKDF(ECDH). NIP-EE tried IETF MLS and has since been superseded by Marmot. Key insight from that lineage: MLS signing keys MUST differ from identity keys — compromise of the long-term identity doesn't compromise group messages.Willow can get real post-compromise security cheaply because it already has the trigger events —
KickMember,RevokePermission,AssignRole,RotateChannelKey. This spec derives a new epoch key on each membership-changing event:Extends existing
EventKind::RotateChannelKeywith anepoch: u64andtrigger: Option<EventHash>. Each rotation'sencrypted_keysdistributes the new epoch key to current members viaencrypt_channel_key_for.SealedContent.key_epoch(which already exists but is unused) becomes authoritative.Also rotates
TopicIdper epoch (blake3("willow-topic-v1" || channel_id || epoch_key_id)) so passive gossip observers lose membership continuity.Spec file:
docs/specs/2026-04-24-epoch-key-rotation.mdProperties provided (explicit)
Non-goals: full FS of in-flight messages, post-quantum, IP/timing privacy.
Open questions for review
ShareHistoricalKeysflag, or out of scope?prev_key || trigger.hash(simpler) vsprev_key || server_state_hash_after(more context, but diverges during DAG merge)RotateChannelKeyarriving before itstriggerevent — hold or reject?Composition with sibling specs
supports_epoch_rotationCommit is unsigned due to harness signing backend failure (same as sibling PRs in this set).
Generated by Claude Code