Skip to content

spec: epoch-driven channel key rotation for PCS#220

Merged
intendednull merged 6 commits into
mainfrom
claude/spec-epoch-key-rotation
Apr 26, 2026
Merged

spec: epoch-driven channel key rotation for PCS#220
intendednull merged 6 commits into
mainfrom
claude/spec-epoch-key-rotation

Conversation

@intendednull
Copy link
Copy Markdown
Owner

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:

epoch_key[N+1] = HKDF-Extract(salt = "willow-epoch-v1",
                              ikm  = epoch_key[N] || triggering_event.hash)

Extends existing EventKind::RotateChannelKey with an epoch: u64 and trigger: Option<EventHash>. Each rotation's encrypted_keys distributes the new epoch key to current members via encrypt_channel_key_for. SealedContent.key_epoch (which already exists but is unused) becomes authoritative.

Also rotates TopicId per 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.md

Properties provided (explicit)

  • Post-compromise security: real — after compromise + at least one membership change, the old key is useless
  • Forward secrecy: partial, client-policy dependent (requires members to actually delete old keys)
  • Metadata hiding: partial via rotating TopicId

Non-goals: full FS of in-flight messages, post-quantum, IP/timing privacy.

Open questions for review

  1. Past-message access policy. Default: new members can't decrypt pre-join messages. Some communities want the opposite (read the archive). Opt-in ShareHistoricalKeys flag, or out of scope?
  2. Split Ed25519 identity from content-signing key now, or follow-up spec? Earlier = less churn.
  3. Derivation input — prev_key || trigger.hash (simpler) vs prev_key || server_state_hash_after (more context, but diverges during DAG merge)
  4. Out-of-order: a RotateChannelKey arriving before its trigger event — hold or reject?
  5. Old-epoch-key retention — required for history replay, but keeping them defeats FS. Per-client TTL?
  6. Rotation storm on rapid kicks — batch into a single rotation window, or accept the overhead?

Composition with sibling specs

  • Seal + gift-wrap DMs: DMs need their own rotation story; this is channels only
  • Negentropy: rotation events sync like any other
  • Relay capability doc: advertise supports_epoch_rotation

Commit is unsigned due to harness signing backend failure (same as sibling PRs in this set).


Generated by Claude Code

Comment on lines +47 to +60
| `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. |

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This table doesn't match the real EventKind at crates/state/src/event.rs:69-174.

Missing / wrong:

  • No Administrator permission exists. The permission set at event.rs:20-33 is {SyncProvider, ManageChannels, ManageRoles, SendMessages, CreateInvite}. Admin status is ProposedAction::GrantAdmin / RevokeAdmin via the Propose/Vote path. Both should rotate — losing admin is a significant authority change.
  • Vote that passes threshold is the actual rotation trigger for KickMember, GrantAdmin, RevokeAdmin — not the Propose event. The trigger hash needs to be the specific Vote whose application caused auto-apply. Spell this out; otherwise the derivation input is ambiguous when multiple votes race.
  • SetPermission (on a role) is missing. A SetPermission { 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.
  • DeleteRole is missing. Same issue: deleting a role that grants SendMessages silently 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

Comment on lines +69 to +89
```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.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Type width mismatch. epoch: u64 here, but SealedContent.key_epoch at messaging/src/lib.rs:166 is u32, and KeyRatchet.epoch at crypto/src/lib.rs:105 is also u32. Pick one and thread it through the whole derivation — if you HKDF-expand with info that includes epoch bytes (the ratchet does), the width matters for the derived bytes being the same on both sides.
  2. Commit channel_id into the derivation. Right now the HKDF-Extract input is prev_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: add channel_id to the salt or the ikm. salt = b"willow-epoch-v1:" || channel_id is clean.

Generated by Claude Code

Comment on lines +132 to +151

`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.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "atomic transition" claim only holds for existing members. For a joining member there's a real race:

  1. AssignRole applied → joining member sees the event via server-ops topic.
  2. RotateChannelKey applied → they decrypt encrypted_keys[self], derive epoch_key[N+1], compute epoch_key_id[N+1], compute the new TopicId.
  3. 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 + RotateChannelKey landing. Costly and non-obvious who "owns" it.
  • SyncProvider oracle — 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

Comment on lines +247 to +256
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?
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Open question #4 (out-of-order) needs a concrete answer in-spec, because the two naive choices are both wrong:

  • Reject RotateChannelKey if trigger is unknown violates Willow's insert flow — deps is advisory per event.rs:195-197, well-formed signed events must be accepted.
  • "Defer until trigger applies" means ServerState needs a pending-rotations queue and apply_event becomes stateful across calls, breaking the pure-function contract of crates/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

Copy link
Copy Markdown
Owner Author

@intendednull intendednull left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Membership event applied at the DAG head.
  2. The author … emits the follow-up RotateChannelKey
  3. 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):

  1. Server-side encrypted key backup — keys backed up under a passphrase-derived secret so a re-logged-in client can recover.
  2. Cross-signing / key verification — out-of-band trust establishment so key-share races are recoverable.
  3. Key request/forward protocol — recipients can ask other members for missing keys.
  4. 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:

  • RequestEpochKey and ProvideEpochKey events 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


Generated by Claude Code

intendednull pushed a commit that referenced this pull request Apr 25, 2026
- 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.
intendednull pushed a commit that referenced this pull request Apr 25, 2026
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>
intendednull added a commit that referenced this pull request Apr 25, 2026
- 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>
intendednull and others added 4 commits April 25, 2026 02:07
- 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>
@intendednull intendednull merged commit 5143654 into main Apr 26, 2026
5 checks passed
@intendednull intendednull deleted the claude/spec-epoch-key-rotation branch April 26, 2026 07:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants