From 9857b6e6a2960b8700dc13dc3e3efaae8cca2d05 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Apr 2026 08:12:34 +0000 Subject: [PATCH 1/3] spec: seal + gift-wrap DM format for metadata privacy Co-authored-by: Claude --- docs/specs/2026-04-24-seal-gift-wrap-dms.md | 336 ++++++++++++++++++++ 1 file changed, 336 insertions(+) create mode 100644 docs/specs/2026-04-24-seal-gift-wrap-dms.md diff --git a/docs/specs/2026-04-24-seal-gift-wrap-dms.md b/docs/specs/2026-04-24-seal-gift-wrap-dms.md new file mode 100644 index 00000000..f0e847b0 --- /dev/null +++ b/docs/specs/2026-04-24-seal-gift-wrap-dms.md @@ -0,0 +1,336 @@ +# Seal + Gift-Wrap DMs for Metadata Privacy + +> **One-sentence summary:** Willow adopts a Nostr-style three-layer +> (rumor → seal → gift wrap) direct-message format that hides sender, +> recipient, content, and timing from passive observers and relays, +> delivered over a dedicated inbox topic and kept off the server's +> event DAG via ephemeral-author wrap events. + +## Motivation + +Willow's existing encryption (`crates/crypto/src/lib.rs:208–241`) is +excellent for **groups**: a shared [`ChannelKey`] and a forward-secret +[`KeyRatchet`] protect message bodies in a channel whose membership is +already public. It solves **content confidentiality** inside a known +set of participants. It does not solve **metadata privacy** for 1:1 or +small-group conversations: + +- Channel topics (`crates/network/src/topics.rs:42`) leak the channel + membership graph. +- Every `Event` (`crates/state/src/event.rs:184–204`) carries a clear- + text `author` [`EndpointId`] and a per-author `seq`/`prev` chain, + so anyone on the wire learns who talks, when, and how often. +- There is no DM primitive at all: users today spin up a "private + channel" which still publishes a channel-creation event, a member + set, and a gossip topic. + +Willow therefore needs a DM format whose **on-wire shape reveals only +"someone sent someone a thing, of roughly this size, at roughly this +time."** Nostr's NIP-44/59/17 stack is a well-studied template; this +spec adapts it to Willow's signed per-author-DAG model. + +## Threat model + +| Adversary | Sees today | Sees after this spec | +|-----------|-----------|----------------------| +| Passive network observer | Sender, recipient channel, timing, size | Ephemeral sender only, size bucket, approximate timing (±2d) | +| Relay / worker (`crates/relay`) | Full channel history, clear authors | Ciphertext blobs addressed to a recipient hint; cannot link to real sender | +| Curious peer not in the DM | Full channel history | Nothing (gift wrap ignored; can't decrypt seal) | +| Recipient's device post-compromise | — | Historical plaintext (no PCS — see non-goals) | +| Quantum adversary | — | Eventually everything (X25519 is not PQ — see non-goals) | + +**This hides:** the real sender's `EndpointId`, the content, the +recipient set for group DMs, and correlation between DM events +and the sender's main author chain. + +**This does NOT hide:** the fact that *someone* sent *a* DM of roughly +some size to *some* recipient, the recipient's pubkey on the +delivery topic, IP addresses at the transport layer, precise timing +against an adversary that logs every gift-wrap event, or the +existence of a conversation once a key is later compromised. + +## Layer structure + +Three layers, mirroring NIP-59 but re-shaped for Willow's event model: + +``` + DMRumor ── no signature, carries the real author & content + │ + ▼ NIP-44-style encrypt to recipient pubkey, sign with real author + EventKind::DMSeal { ciphertext } (real author's DAG, seq++) + │ + ▼ NIP-44-style encrypt to recipient pubkey, sign with EPHEMERAL key + EventKind::DMGiftWrap { ciphertext, recipient_hint } + (ephemeral author's DAG, seq=1) +``` + +### Rumor (unsigned, off-DAG) + +```rust +// crates/messaging/src/dm.rs (new) +pub struct DMRumor { + pub author_peer_id: EndpointId, // real author + pub timestamp_hint_ms: u64, // jittered up to 2d in the past + pub content: Content, // reuse willow_messaging::Content +} +``` + +`DMRumor` is deliberately **not** a full `Event` +(`crates/state/src/event.rs:184`): an `Event` carries `seq`, `prev`, +`deps`, and `sig` that either tie the rumor to the real author's +chain (defeating the point) or force a parallel chain (adds +complexity without benefit). A rumor is serialized with `bincode` +via `willow-transport` and encrypted raw. + +**No signature.** This preserves deniability: a leaked rumor cannot +be cryptographically attributed to its author. + +### Seal (`EventKind::DMSeal`) + +A new `EventKind` variant on the real author's DAG: + +```rust +EventKind::DMSeal { + ciphertext: Vec, // NIP-44 v1 payload over DMRumor +} +``` + +Rules: + +- Signed by the **real author** and inserted at the next `seq` on + their chain, like any other event. +- `deps` MUST be empty (no cross-author dependency leaks a link to + the recipient). +- No field names the recipient; the seal's only public metadata is + "author X produced a DMSeal at seq N." +- `required_permission()` in `crates/state/src/materialize.rs` maps + this variant to `None` (unrestricted — DMs aren't server actions). +- `apply_mutation()` treats it as **inert**: the seal exists on the + author's DAG for local recall but does **not** change + `ServerState`. + +Verifier requirement: a recipient that decrypts a `DMSeal` MUST +check that the decrypted `DMRumor.author_peer_id` equals the seal +event's signed `author`. If it doesn't, the rumor is discarded. This +prevents "pubkey swap" impersonation where an attacker replaces the +rumor author field. + +### Gift wrap (`EventKind::DMGiftWrap`) + +A new `EventKind` variant authored by a **fresh ephemeral Ed25519 +identity generated per recipient per message**: + +```rust +EventKind::DMGiftWrap { + ciphertext: Vec, // NIP-44 v1 payload over the Seal event bytes + recipient_hint: EndpointId, // recipient pubkey (routing only) +} +``` + +Rules: + +- The wrapping `Event.author` is the ephemeral key, not the real + sender. `seq = 1`, `prev = EventHash::ZERO`, `deps = []`. +- The ephemeral key is used exactly once and then discarded. +- `timestamp_hint_ms` is independently jittered up to 2 days in the + past (the seal's timestamp is also jittered — independently). +- `recipient_hint` is the only metadata needed for delivery routing. + It does **not** prove the recipient was actually addressed (an + attacker can forge a hint); it's a fast filter. + +Because the gift wrap lives on a single-event ephemeral-author DAG, +it does not interfere with any real peer's `seq`/`prev` chain and +does not need DAG-level merge (`crates/state/src/materialize.rs`). + +## Payload format (NIP-44 v1, Willow-flavored) + +Mirrors NIP-44 v2 byte-for-byte except for the HKDF labels; version +byte is `0x01` to reserve future migration independently of Nostr. + +| Field | Size | Notes | +|-------|------|-------| +| `version` | 1 B | `0x01` | +| `nonce` | 32 B | CSPRNG, also used as HKDF-expand `info` | +| `ciphertext` | variable | ChaCha20 (counter=0) output, input is padded plaintext | +| `mac` | 32 B | HMAC-SHA256(hmac_key, nonce ‖ ciphertext) | + +Key derivation: + +``` +shared = X25519(ed25519_to_x25519(sender_sk), ed25519_to_x25519(recipient_pk)) +conv_key = HKDF-Extract(salt = "willow-dm-v1", ikm = shared) +expanded = HKDF-Expand(prk = conv_key, info = nonce, L = 76) +chacha_key = expanded[0..32] +chacha_iv = expanded[32..44] // 12 bytes +hmac_key = expanded[44..76] +``` + +Ed25519→X25519 reuses `identity_to_x25519` / +`ed25519_public_to_x25519` (`crates/crypto/src/lib.rs:318–343`). The +HKDF label `"willow-dm-v1"` distinguishes this derivation from the +existing `"willow-channel-key-wrap"` label +(`crates/crypto/src/lib.rs:360`). + +Padded plaintext layout matches NIP-44: + +``` +[u16 BE length][plaintext][zero padding] +``` + +with power-of-two bucket sizes (min 32 B): + +``` +if len ≤ 32: bucket = 32 +else: next = 2^(floor(log2(len-1)) + 1) + chunk = max(32, next / 8) + bucket = chunk * (ceil(len / chunk)) +``` + +MAC intentionally uses encrypt-then-HMAC (not Poly1305) so the +construction mirrors NIP-44 exactly, keeping test vectors portable. + +## Delivery topic + +Two candidates, both with leaks: + +| Option | Pro | Con | +|--------|-----|-----| +| Per-recipient topic `_willow_inbox/` | Small fan-out; recipient only subscribes to their own inbox | Observer learns which pubkeys are active DM recipients | +| Shared `_willow_inbox` topic | No per-pubkey topic-id leak | Every wrap floods every peer; DoS/bandwidth cost | + +**This spec specifies the per-recipient topic.** The topic id is +`topic_id(&format!("_willow_inbox/{}", hex(blake3(recipient_pk))))` +(`crates/network/src/topics.rs:12`). Recipients subscribe to their +own inbox on startup. Senders subscribe briefly to publish, then +unsubscribe. The recipient-pubkey leak is judged acceptable because +the pubkey is already public-key material; the shared-inbox DoS is +not. + +Future optimization: workers can serve as inbox aggregators that +pull on behalf of offline peers (see *Open questions*). + +## State-machine integration + +Gift wraps and seals both deserialize to `Event`s and ride the same +wire pipeline (`identity::pack` / `unpack`). The state machine: + +1. `dag.insert()` accepts the wrap like any other event (signature + verifies against the ephemeral author). +2. `apply_event()` in `crates/state/src/materialize.rs` recognises + `DMGiftWrap` / `DMSeal` and short-circuits: it does **not** + mutate `ServerState`. Both return `None` from + `required_permission()` and are listed in the catch-all comment + alongside `SetProfile` (see + `docs/specs/2026-04-12-state-authority-and-mutations.md:96–107`). +3. A separate `DMInbox` side-state (local, per-client, not shared) + collects decrypted rumors, keyed by `(participant_set, rumor_id)` + where `rumor_id = blake3(bincode(DMRumor))`. +4. **Dedup** happens at the rumor layer: two different ephemeral + wraps of the same rumor produce identical `rumor_id`s after + decryption. + +Because the wrap's DAG has only one event, `ManagedDag::create_event` +for DM send bypasses the normal per-author chain and instead +instantiates a throwaway identity, produces the seal on the real +chain first, packs it, then constructs the wrap event. + +## Multi-recipient DMs + +Group DMs produce **N independent gift wraps**, one per recipient +(including the sender themselves so their other devices see a copy, +matching NIP-17's rule). There is **no shared identifier on the +wire**: each wrap has a distinct ephemeral author, distinct +ciphertext, distinct timestamp. Clients reconstruct a "room" locally +by hashing the sorted participant set from the decrypted rumor's +`Content`-level metadata (to be defined in a follow-up — for now +group DM is just "DM to these N pubkeys"). + +A shared room identifier on-wire would defeat the metadata-hiding +goal; the cost is O(N) wrap events per group message. + +## Timestamp jitter + +Each layer independently jitters `timestamp_hint_ms`: + +``` +t_wrap = now_ms - rand(0, 2 * 86_400_000) +t_seal = now_ms - rand(0, 2 * 86_400_000) +``` + +Independent jitter breaks the obvious "wrap.ts == seal.ts" linkage a +relay could exploit. `HLC` (`crates/messaging/src/hlc.rs`) is **not** +used for DMs — HLC is designed for totally-ordered channel history +and would leak real sender clocks. + +## Tests + +| # | Property | Location | +|---|----------|----------| +| 1 | NIP-44 KAT round-trip (seal + open a `DMRumor`) | `crates/crypto/src/dm.rs` tests | +| 2 | Pubkey-swap imposter detected (rumor.author ≠ seal.author → rejected) | `crates/crypto/src/dm.rs` tests | +| 3 | Ephemeral key single-use (two wraps of the same seal have distinct authors) | `crates/client/src/dm.rs` tests | +| 4 | Wrap/seal timestamps uncorrelated across 1000 samples (χ² over pairing) | `crates/crypto/src/dm.rs` tests | +| 5 | `DMSeal`/`DMGiftWrap` leave `ServerState` unchanged | `crates/state/src/tests.rs` | +| 6 | Wrong recipient cannot decrypt (AEAD fails) | `crates/crypto/src/dm.rs` tests | +| 7 | Padding buckets match NIP-44 vectors for sizes 1, 32, 33, 145, 600 | `crates/crypto/src/dm.rs` tests | +| 8 | Tampered MAC rejected before ChaCha20 runs | `crates/crypto/src/dm.rs` tests | + +## Non-goals + +| Property | Why deferred | +|----------|--------------| +| Forward secrecy within a conversation | Would need per-session ratchet (Double Ratchet / MLS). The existing `KeyRatchet` (`crates/crypto/src/lib.rs:102–165`) is group-keyed and not suitable for 1:1 over independent wrap events. | +| Post-compromise security | Same — requires a ratchet with key update primitives. | +| Length hiding beyond bucket size | Requires per-message padding to a global ceiling; prohibitive for long messages. | +| IP-address hiding | Transport concern; iroh relays see endpoint IPs. Out of scope; see transport-privacy work (TBD spec). | +| Precise-timing hiding | Requires cover traffic; not in this spec. | +| Post-quantum confidentiality | Willow uses X25519/Ed25519 throughout; a PQ migration is a whole-codebase spec. | + +## Open questions + +1. **Per-recipient vs shared inbox topic.** This spec picks + per-recipient. Is the pubkey-activity leak acceptable long-term, + or should workers proxy to hide it? +2. **MLS for group DMs.** For groups > ~8, N wraps scale linearly. + Should a future spec adopt MLS (RFC 9420) and treat this spec as + the 1:1 fallback? +3. **Worker/relay TTL.** Gift wraps bypass `ServerState` so workers + (`crates/relay`) have no natural retention signal. Should wraps + expire after e.g. 30 days, or should each recipient ack a wrap + to unlock deletion? Current replay-node 1000-event cap would + drop DMs unpredictably. +4. **Forward secrecy / epoch rotation.** A sibling epoch-key- + rotation spec would give DMs a sliding-window FS property by + re-deriving the conversation key on a timer. Prerequisite: a DM + session abstraction that doesn't exist yet. +5. **Identity-key vs session-key separation.** Willow today uses one + Ed25519 key for signing, DH (via conversion), and peer ID. For + DMs we may want a per-device session key bound to the identity + — both to enable FS and to limit blast radius of a device + compromise. Should this spec ship with session keys, or land in + a follow-up? +6. **Ephemeral-author DAG pollution.** Every DM spawns a one-event + DAG. Do we prune ephemeral-author DAGs after the receiver has + processed the wrap, or let them accumulate indefinitely at + workers? + +## Checklists + +### Adding the `DMSeal` and `DMGiftWrap` variants + +1. Add two variants to `EventKind` in `crates/state/src/event.rs`. +2. Leave both out of `required_permission()`; add them to the + catch-all comment listing unrestricted variants. +3. In `apply_mutation()`, match both variants and return without + mutating `ServerState`. +4. Add state-machine tests (#5 above) verifying inertness. +5. Add a `willow-crypto` module implementing the NIP-44 v1 payload + (`seal_dm`, `open_dm`). +6. Add a `willow-client` API: `send_dm(recipients: &[EndpointId], content: Content)`. +7. Add an inbox subscription in `willow-client` startup. + +### Adding a new DM content type + +Reuse `willow_messaging::Content` — no new types needed. If a DM- +only content variant appears later, add it to `Content` and treat +it identically on the rumor path. From 88cdc3b8241b4af7d3c2e0fb2b013f8dd58d214f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Apr 2026 07:09:57 +0000 Subject: [PATCH 2/3] spec(#218): defer DM implementation in favor of MLS-over-Willow - audit pass --- docs/specs/2026-04-24-seal-gift-wrap-dms.md | 529 +++++++++----------- 1 file changed, 243 insertions(+), 286 deletions(-) diff --git a/docs/specs/2026-04-24-seal-gift-wrap-dms.md b/docs/specs/2026-04-24-seal-gift-wrap-dms.md index f0e847b0..2e1d4de4 100644 --- a/docs/specs/2026-04-24-seal-gift-wrap-dms.md +++ b/docs/specs/2026-04-24-seal-gift-wrap-dms.md @@ -1,336 +1,293 @@ -# Seal + Gift-Wrap DMs for Metadata Privacy - -> **One-sentence summary:** Willow adopts a Nostr-style three-layer -> (rumor → seal → gift wrap) direct-message format that hides sender, -> recipient, content, and timing from passive observers and relays, -> delivered over a dedicated inbox topic and kept off the server's -> event DAG via ephemeral-author wrap events. - -## Motivation - -Willow's existing encryption (`crates/crypto/src/lib.rs:208–241`) is -excellent for **groups**: a shared [`ChannelKey`] and a forward-secret -[`KeyRatchet`] protect message bodies in a channel whose membership is -already public. It solves **content confidentiality** inside a known -set of participants. It does not solve **metadata privacy** for 1:1 or -small-group conversations: - -- Channel topics (`crates/network/src/topics.rs:42`) leak the channel - membership graph. -- Every `Event` (`crates/state/src/event.rs:184–204`) carries a clear- - text `author` [`EndpointId`] and a per-author `seq`/`prev` chain, - so anyone on the wire learns who talks, when, and how often. -- There is no DM primitive at all: users today spin up a "private - channel" which still publishes a channel-creation event, a member - set, and a gossip topic. - -Willow therefore needs a DM format whose **on-wire shape reveals only -"someone sent someone a thing, of roughly this size, at roughly this -time."** Nostr's NIP-44/59/17 stack is a well-studied template; this -spec adapts it to Willow's signed per-author-DAG model. - -## Threat model - -| Adversary | Sees today | Sees after this spec | -|-----------|-----------|----------------------| -| Passive network observer | Sender, recipient channel, timing, size | Ephemeral sender only, size bucket, approximate timing (±2d) | -| Relay / worker (`crates/relay`) | Full channel history, clear authors | Ciphertext blobs addressed to a recipient hint; cannot link to real sender | -| Curious peer not in the DM | Full channel history | Nothing (gift wrap ignored; can't decrypt seal) | -| Recipient's device post-compromise | — | Historical plaintext (no PCS — see non-goals) | -| Quantum adversary | — | Eventually everything (X25519 is not PQ — see non-goals) | - -**This hides:** the real sender's `EndpointId`, the content, the -recipient set for group DMs, and correlation between DM events -and the sender's main author chain. - -**This does NOT hide:** the fact that *someone* sent *a* DM of roughly -some size to *some* recipient, the recipient's pubkey on the -delivery topic, IP addresses at the transport layer, precise timing -against an adversary that logs every gift-wrap event, or the -existence of a conversation once a key is later compromised. - -## Layer structure - -Three layers, mirroring NIP-59 but re-shaped for Willow's event model: +# Direct Messages — design notes and deferral to MLS-over-Willow + +> **One-sentence summary:** This spec captures lessons learned from a +> Nostr-NIP-17/44/59-inspired investigation of Willow DMs. The +> conclusion is that we should **NOT** ship the seal+gift-wrap design +> directly. Instead we plan to specify **MLS-over-Willow (RFC 9420)** +> in a follow-up, retaining NIP-44's metadata-hiding patterns as a +> transport encoding *on top of* MLS application messages. + +## Status + +**Deferred.** No `EventKind` variants are added by this spec. No code +lands as a result of this spec. The wire-format work below is preserved +in Appendix A as research notes for the future MLS-over-Willow spec. + +## Why we are deferring + +After a Round 2 review of the seal+gift-wrap design against the wider +secure-messaging landscape (Signal, Matrix, XMTP, Bluesky, Nostr's own +NIP-EE / Marmot), the clean-architecture answer is to defer first-class +DM implementation in favor of MLS-over-Willow. + +- **NIP-59 is a privacy envelope without a forward-secrecy layer + underneath.** Signal's Sealed Sender works because it sits on top of + the Double Ratchet. Nostr's NIP-17 explicitly lacks FS / PCS — and + the Nostr ecosystem itself has moved on to NIP-EE / Marmot, both + MLS-based. Shipping seal+gift-wrap on Willow would be repeating + Nostr's known-bad starting point. + +- **Matrix Megolm is the lived warning of group-chat-over-gossip + without MLS.** Roughly seven years of UTD ("Unable to Decrypt") + production bugs — sender goes offline mid-key-share, partial + delivery, session corruption, device-list races — all live exactly + in the seam this spec was creating between "DM rumor", "per-author + seal DAG", and "ephemeral wrap on inbox topic". MLS removes the + seam. + +- **MLS solves these problems.** RFC 9420's TreeKEM gives O(log N) + group rotation (vs O(N) gift wraps in this spec). Welcome messages + atomically bind admit-and-key-distribution. RFC 9750 specifies the + architecture for deployment. XMTP, Bluesky, and Cisco Webex have + all shipped against RFC 9420; the convergence is industry-wide. + +- **DAG concurrency.** Willow's event DAG tolerates concurrent + membership events; MLS assumes serialized Commits. Channels must + linearize to fit MLS, but channel-level linearization is a strictly + smaller scope than full server-state linearization, and is a + tractable design problem for the follow-up spec. + +The seal+gift-wrap design captured here would solve a metadata-hiding +problem while leaving forward secrecy, post-compromise security, +multi-device, and group-DM scaling unsolved — and would have to be +ripped out the moment we adopted MLS for groups. That is not a +sequence we want to commit to. + +## Crypto lessons captured for the MLS spec + +The investigation surfaced specific findings that the future +MLS-over-Willow spec must absorb. + +### Deniability claim was structurally false + +The original seal layer used the real author's Ed25519 signature over +the encrypted rumor. Once a recipient (or a future device-compromise) +recovers the rumor plaintext, that signature **non-repudiably binds the +author to the rumor**. Calling this "deniable" because the seal was +encrypted to one recipient was sleight-of-hand: the cryptographic +binding survives plaintext recovery. + +The future spec must be honest about this. Either: + +- Drop the deniability claim entirely; or +- Use a designated-verifier MAC (e.g. an HMAC keyed from the X25519 + shared secret) instead of a signature, so the recipient cannot + prove authorship to a third party. + +### Per-recipient inbox topic leaks the active-DM-recipient graph + +The inbox topic `_willow_inbox/` lets workers +and any subscribed observer enumerate which pubkeys are *currently +receiving* DM traffic, by watching subscription patterns. The pubkey +itself is public, but the **subscription graph** ("which pubkeys are +DM-active right now") is new metadata. + +The future spec must address this — bloom-filter or k-anonymity +buckets (multiple recipients share a bucket id), worker-mediated +fetch (recipients pull from an aggregator), or explicit acceptance +of the leak. It must not be silently inherited. + +### Per-author DAG pollution from one-shot ephemeral chains + +Each gift wrap in the original design spawned a single-event +ephemeral-author DAG. Workers retain these forever (no natural +retention signal) and the per-author DAG count grows linearly with +DM volume across the network. This is an existing data-model +problem the spec made worse, not better. + +**MLS application messages should NOT enter the per-author DAG.** +They belong on a separate transport path — e.g. an inbox topic with +worker-bounded retention, or a fetch-on-demand store — explicitly +outside the event-sourced state machine. + +### NIP-44 v2 payload format is reusable, but must be used verbatim + +The NIP-44 v2 AEAD construction — ChaCha20 + HMAC-SHA256, 76-byte +HKDF-Expand split into 32 / 12 / 32 (chacha key / iv / hmac key), +length-prefixed power-of-two padding — IS a reasonable AEAD primitive +for MLS application-message ciphertexts at the framing layer. + +It must be used **verbatim**: no `"willow-dm-v1"` HKDF salt fork, no +version-byte renumbering. Preserving identical KAT vectors with +upstream NIP-44 keeps cross-implementation interop and lets us reuse +the existing test corpus. A custom salt buys nothing and breaks every +external test vector. + +### Multi-device must be designed in from day one + +Willow currently uses one Ed25519 key for signing, peer ID, and (via +conversion) DH. The future MLS spec must split: + +- A long-term **identity key** that names the user across devices. +- Per-device **session keys** that participate in MLS group state. + +This is the Sesame-class design. Adding multi-device after the fact +(as Signal and Matrix both learned) is dramatically harder than +designing it in. It cannot be a v2 feature. + +## Non-goals (for the future MLS spec) + +The future MLS-over-Willow spec MUST satisfy: + +- **MUST provide forward secrecy.** A device compromise today does not + reveal yesterday's plaintext. +- **MUST provide post-compromise security.** Recovery from a device + compromise via key rotation, without re-establishing the group out + of band. +- **MUST handle multi-device.** Identity key separated from session + key; new devices join via a user-scoped enrollment flow. +- **MUST avoid DAG pollution.** MLS application messages live on a + transport path that is not the event-sourced per-author DAG. +- **MUST hide metadata at least as well as NIP-59.** Sender, content, + and recipient set are not visible to passive observers or workers + beyond a coarse routing hint. + +These are non-negotiable preconditions for the follow-up spec — not +items to be deferred again. -``` - DMRumor ── no signature, carries the real author & content - │ - ▼ NIP-44-style encrypt to recipient pubkey, sign with real author - EventKind::DMSeal { ciphertext } (real author's DAG, seq++) - │ - ▼ NIP-44-style encrypt to recipient pubkey, sign with EPHEMERAL key - EventKind::DMGiftWrap { ciphertext, recipient_hint } - (ephemeral author's DAG, seq=1) -``` +## Open questions -### Rumor (unsigned, off-DAG) +1. **When do we start the MLS-over-Willow spec?** A draft should + begin once the channel-linearization design (a prerequisite for + serialized Commits) is sketched. -```rust -// crates/messaging/src/dm.rs (new) -pub struct DMRumor { - pub author_peer_id: EndpointId, // real author - pub timestamp_hint_ms: u64, // jittered up to 2d in the past - pub content: Content, // reuse willow_messaging::Content -} -``` +2. **Who owns it?** The follow-up spec spans `willow-state` (channel + linearization), `willow-crypto` (MLS ciphersuite glue), + `willow-network` (transport path that bypasses the per-author + DAG), and `willow-client` (multi-device enrollment). -`DMRumor` is deliberately **not** a full `Event` -(`crates/state/src/event.rs:184`): an `Event` carries `seq`, `prev`, -`deps`, and `sig` that either tie the rumor to the real author's -chain (defeating the point) or force a parallel chain (adds -complexity without benefit). A rumor is serialized with `bincode` -via `willow-transport` and encrypted raw. +3. **Library choice.** `openmls` (Rust, RFC 9420 conformant, used by + several production deployments) is the leading candidate. Open + questions: WASM compatibility, ciphersuite selection, storage + trait fit with our `EventStore` abstraction. -**No signature.** This preserves deniability: a leaked rumor cannot -be cryptographically attributed to its author. +4. **Ciphersuite.** RFC 9420 mandates X25519 + Ed25519 + ChaCha20- + Poly1305 + SHA-256 as one valid option, which aligns with + Willow's existing primitives. -### Seal (`EventKind::DMSeal`) +5. **Channel linearization scope.** What exactly must serialize for + Commits? Only membership-changing events, or all channel events? -A new `EventKind` variant on the real author's DAG: +6. **Inbox-topic privacy.** Bloom buckets vs worker-mediated fetch + vs accepted leak — to be decided in the MLS spec, not here. -```rust -EventKind::DMSeal { - ciphertext: Vec, // NIP-44 v1 payload over DMRumor -} -``` +## Sources -Rules: - -- Signed by the **real author** and inserted at the next `seq` on - their chain, like any other event. -- `deps` MUST be empty (no cross-author dependency leaks a link to - the recipient). -- No field names the recipient; the seal's only public metadata is - "author X produced a DMSeal at seq N." -- `required_permission()` in `crates/state/src/materialize.rs` maps - this variant to `None` (unrestricted — DMs aren't server actions). -- `apply_mutation()` treats it as **inert**: the seal exists on the - author's DAG for local recall but does **not** change - `ServerState`. - -Verifier requirement: a recipient that decrypts a `DMSeal` MUST -check that the decrypted `DMRumor.author_peer_id` equals the seal -event's signed `author`. If it doesn't, the rumor is discarded. This -prevents "pubkey swap" impersonation where an attacker replaces the -rumor author field. - -### Gift wrap (`EventKind::DMGiftWrap`) - -A new `EventKind` variant authored by a **fresh ephemeral Ed25519 -identity generated per recipient per message**: - -```rust -EventKind::DMGiftWrap { - ciphertext: Vec, // NIP-44 v1 payload over the Seal event bytes - recipient_hint: EndpointId, // recipient pubkey (routing only) -} -``` +- RFC 9420 — *The Messaging Layer Security (MLS) Protocol*. +- RFC 9750 — *The Messaging Layer Security (MLS) Architecture*. +- Signal blog — *Sealed Sender for Signal* (technical preview). +- Matrix.org — *MatrixConf 2024: Unable To Decrypt — A Postmortem*. +- Marmot Protocol — MLS-over-Nostr specification (NIP-EE precursor). +- XMTP — *Why XMTP chose MLS* (engineering rationale). +- Bluesky — MLS direct-messaging design notes. + +--- + +## Appendix A: Investigated wire format (not adopted) + +> **DEPRECATED — DO NOT IMPLEMENT.** The material in this appendix +> describes a Nostr-NIP-17/44/59-inspired design that was investigated +> and **rejected** in favor of MLS-over-Willow (see body of spec). +> It is preserved only as research notes for the future MLS spec +> author. **No `EventKind` variants are added. No code lands.** + +### A.1 Layer structure (investigated, rejected) -Rules: +Three layers, mirroring NIP-59: -- The wrapping `Event.author` is the ephemeral key, not the real - sender. `seq = 1`, `prev = EventHash::ZERO`, `deps = []`. -- The ephemeral key is used exactly once and then discarded. -- `timestamp_hint_ms` is independently jittered up to 2 days in the - past (the seal's timestamp is also jittered — independently). -- `recipient_hint` is the only metadata needed for delivery routing. - It does **not** prove the recipient was actually addressed (an - attacker can forge a hint); it's a fast filter. +``` + DMRumor ── no signature, carries the real author & content + │ + ▼ NIP-44-style encrypt to recipient pubkey, sign with real author + [Seal payload] (would have been on real author's DAG) + │ + ▼ NIP-44-style encrypt to recipient pubkey, sign with EPHEMERAL key + [Gift-wrap payload] (would have been on ephemeral author DAG) +``` + +The rumor carried `author_peer_id`, a jittered timestamp hint, and a +`willow_messaging::Content`. The seal wrapped the rumor under an X25519 +shared secret to the recipient. The gift wrap wrapped the seal under +a fresh single-use ephemeral key. -Because the gift wrap lives on a single-event ephemeral-author DAG, -it does not interfere with any real peer's `seq`/`prev` chain and -does not need DAG-level merge (`crates/state/src/materialize.rs`). +This appendix omits the originally-proposed `EventKind::DMSeal` and +`EventKind::DMGiftWrap` variants from any implementation framing. +**This spec does NOT add new `EventKind` variants. Implementation is +deferred to the MLS-over-Willow follow-up.** -## Payload format (NIP-44 v1, Willow-flavored) +### A.2 Payload format (NIP-44 v2, investigated) -Mirrors NIP-44 v2 byte-for-byte except for the HKDF labels; version -byte is `0x01` to reserve future migration independently of Nostr. +Mirrors NIP-44 v2: | Field | Size | Notes | |-------|------|-------| -| `version` | 1 B | `0x01` | +| `version` | 1 B | `0x02` (do NOT fork to `0x01` — keep KAT compatibility) | | `nonce` | 32 B | CSPRNG, also used as HKDF-expand `info` | | `ciphertext` | variable | ChaCha20 (counter=0) output, input is padded plaintext | | `mac` | 32 B | HMAC-SHA256(hmac_key, nonce ‖ ciphertext) | -Key derivation: +Key derivation (verbatim NIP-44 v2; **do not** introduce a Willow- +specific salt): ``` shared = X25519(ed25519_to_x25519(sender_sk), ed25519_to_x25519(recipient_pk)) -conv_key = HKDF-Extract(salt = "willow-dm-v1", ikm = shared) +conv_key = HKDF-Extract(salt = "nip44-v2", ikm = shared) expanded = HKDF-Expand(prk = conv_key, info = nonce, L = 76) chacha_key = expanded[0..32] -chacha_iv = expanded[32..44] // 12 bytes +chacha_iv = expanded[32..44] // 12 bytes hmac_key = expanded[44..76] ``` -Ed25519→X25519 reuses `identity_to_x25519` / -`ed25519_public_to_x25519` (`crates/crypto/src/lib.rs:318–343`). The -HKDF label `"willow-dm-v1"` distinguishes this derivation from the -existing `"willow-channel-key-wrap"` label -(`crates/crypto/src/lib.rs:360`). - -Padded plaintext layout matches NIP-44: +Padded plaintext layout: ``` [u16 BE length][plaintext][zero padding] ``` -with power-of-two bucket sizes (min 32 B): +Power-of-two bucket sizes (min 32 B): ``` if len ≤ 32: bucket = 32 -else: next = 2^(floor(log2(len-1)) + 1) +else: next = 2^(floor(log2(len-1)) + 1) chunk = max(32, next / 8) bucket = chunk * (ceil(len / chunk)) ``` -MAC intentionally uses encrypt-then-HMAC (not Poly1305) so the -construction mirrors NIP-44 exactly, keeping test vectors portable. +Encrypt-then-HMAC (not Poly1305) preserves NIP-44 KAT portability. +The future MLS spec should reuse this construction at the framing +layer **without** modification. -## Delivery topic +### A.3 Delivery topic (investigated) -Two candidates, both with leaks: +Two candidates were considered: | Option | Pro | Con | |--------|-----|-----| -| Per-recipient topic `_willow_inbox/` | Small fan-out; recipient only subscribes to their own inbox | Observer learns which pubkeys are active DM recipients | -| Shared `_willow_inbox` topic | No per-pubkey topic-id leak | Every wrap floods every peer; DoS/bandwidth cost | - -**This spec specifies the per-recipient topic.** The topic id is -`topic_id(&format!("_willow_inbox/{}", hex(blake3(recipient_pk))))` -(`crates/network/src/topics.rs:12`). Recipients subscribe to their -own inbox on startup. Senders subscribe briefly to publish, then -unsubscribe. The recipient-pubkey leak is judged acceptable because -the pubkey is already public-key material; the shared-inbox DoS is -not. - -Future optimization: workers can serve as inbox aggregators that -pull on behalf of offline peers (see *Open questions*). - -## State-machine integration - -Gift wraps and seals both deserialize to `Event`s and ride the same -wire pipeline (`identity::pack` / `unpack`). The state machine: - -1. `dag.insert()` accepts the wrap like any other event (signature - verifies against the ephemeral author). -2. `apply_event()` in `crates/state/src/materialize.rs` recognises - `DMGiftWrap` / `DMSeal` and short-circuits: it does **not** - mutate `ServerState`. Both return `None` from - `required_permission()` and are listed in the catch-all comment - alongside `SetProfile` (see - `docs/specs/2026-04-12-state-authority-and-mutations.md:96–107`). -3. A separate `DMInbox` side-state (local, per-client, not shared) - collects decrypted rumors, keyed by `(participant_set, rumor_id)` - where `rumor_id = blake3(bincode(DMRumor))`. -4. **Dedup** happens at the rumor layer: two different ephemeral - wraps of the same rumor produce identical `rumor_id`s after - decryption. - -Because the wrap's DAG has only one event, `ManagedDag::create_event` -for DM send bypasses the normal per-author chain and instead -instantiates a throwaway identity, produces the seal on the real -chain first, packs it, then constructs the wrap event. - -## Multi-recipient DMs - -Group DMs produce **N independent gift wraps**, one per recipient -(including the sender themselves so their other devices see a copy, -matching NIP-17's rule). There is **no shared identifier on the -wire**: each wrap has a distinct ephemeral author, distinct -ciphertext, distinct timestamp. Clients reconstruct a "room" locally -by hashing the sorted participant set from the decrypted rumor's -`Content`-level metadata (to be defined in a follow-up — for now -group DM is just "DM to these N pubkeys"). - -A shared room identifier on-wire would defeat the metadata-hiding -goal; the cost is O(N) wrap events per group message. - -## Timestamp jitter - -Each layer independently jitters `timestamp_hint_ms`: +| Per-recipient `_willow_inbox/` | Small fan-out | Leaks DM-recipient activity graph via subscriptions | +| Shared `_willow_inbox` | No per-pubkey topic-id leak | Every wrap floods every peer; DoS | -``` -t_wrap = now_ms - rand(0, 2 * 86_400_000) -t_seal = now_ms - rand(0, 2 * 86_400_000) -``` +Neither is acceptable as-is. The MLS spec must address the +subscription-graph leak explicitly (see "Crypto lessons" above). -Independent jitter breaks the obvious "wrap.ts == seal.ts" linkage a -relay could exploit. `HLC` (`crates/messaging/src/hlc.rs`) is **not** -used for DMs — HLC is designed for totally-ordered channel history -and would leak real sender clocks. - -## Tests - -| # | Property | Location | -|---|----------|----------| -| 1 | NIP-44 KAT round-trip (seal + open a `DMRumor`) | `crates/crypto/src/dm.rs` tests | -| 2 | Pubkey-swap imposter detected (rumor.author ≠ seal.author → rejected) | `crates/crypto/src/dm.rs` tests | -| 3 | Ephemeral key single-use (two wraps of the same seal have distinct authors) | `crates/client/src/dm.rs` tests | -| 4 | Wrap/seal timestamps uncorrelated across 1000 samples (χ² over pairing) | `crates/crypto/src/dm.rs` tests | -| 5 | `DMSeal`/`DMGiftWrap` leave `ServerState` unchanged | `crates/state/src/tests.rs` | -| 6 | Wrong recipient cannot decrypt (AEAD fails) | `crates/crypto/src/dm.rs` tests | -| 7 | Padding buckets match NIP-44 vectors for sizes 1, 32, 33, 145, 600 | `crates/crypto/src/dm.rs` tests | -| 8 | Tampered MAC rejected before ChaCha20 runs | `crates/crypto/src/dm.rs` tests | - -## Non-goals - -| Property | Why deferred | -|----------|--------------| -| Forward secrecy within a conversation | Would need per-session ratchet (Double Ratchet / MLS). The existing `KeyRatchet` (`crates/crypto/src/lib.rs:102–165`) is group-keyed and not suitable for 1:1 over independent wrap events. | -| Post-compromise security | Same — requires a ratchet with key update primitives. | -| Length hiding beyond bucket size | Requires per-message padding to a global ceiling; prohibitive for long messages. | -| IP-address hiding | Transport concern; iroh relays see endpoint IPs. Out of scope; see transport-privacy work (TBD spec). | -| Precise-timing hiding | Requires cover traffic; not in this spec. | -| Post-quantum confidentiality | Willow uses X25519/Ed25519 throughout; a PQ migration is a whole-codebase spec. | +### A.4 Multi-recipient (investigated) -## Open questions +Group DMs would have produced **N independent gift wraps**, one per +recipient (including the sender's own other devices). This is O(N) per +message — exactly the cost MLS's TreeKEM amortizes to O(log N), and a +direct motivator for moving to MLS for any group of more than ~8. + +### A.5 Timestamp jitter (investigated) + +Each layer independently jittered `timestamp_hint_ms` up to 2 days +into the past, breaking the obvious `wrap.ts == seal.ts` linkage. +HLC was deliberately not used (it would leak real sender clocks). +The MLS spec inherits the same constraint. + +### A.6 Threat model (investigated, summary only) -1. **Per-recipient vs shared inbox topic.** This spec picks - per-recipient. Is the pubkey-activity leak acceptable long-term, - or should workers proxy to hide it? -2. **MLS for group DMs.** For groups > ~8, N wraps scale linearly. - Should a future spec adopt MLS (RFC 9420) and treat this spec as - the 1:1 fallback? -3. **Worker/relay TTL.** Gift wraps bypass `ServerState` so workers - (`crates/relay`) have no natural retention signal. Should wraps - expire after e.g. 30 days, or should each recipient ack a wrap - to unlock deletion? Current replay-node 1000-event cap would - drop DMs unpredictably. -4. **Forward secrecy / epoch rotation.** A sibling epoch-key- - rotation spec would give DMs a sliding-window FS property by - re-deriving the conversation key on a timer. Prerequisite: a DM - session abstraction that doesn't exist yet. -5. **Identity-key vs session-key separation.** Willow today uses one - Ed25519 key for signing, DH (via conversion), and peer ID. For - DMs we may want a per-device session key bound to the identity - — both to enable FS and to limit blast radius of a device - compromise. Should this spec ship with session keys, or land in - a follow-up? -6. **Ephemeral-author DAG pollution.** Every DM spawns a one-event - DAG. Do we prune ephemeral-author DAGs after the receiver has - processed the wrap, or let them accumulate indefinitely at - workers? - -## Checklists - -### Adding the `DMSeal` and `DMGiftWrap` variants - -1. Add two variants to `EventKind` in `crates/state/src/event.rs`. -2. Leave both out of `required_permission()`; add them to the - catch-all comment listing unrestricted variants. -3. In `apply_mutation()`, match both variants and return without - mutating `ServerState`. -4. Add state-machine tests (#5 above) verifying inertness. -5. Add a `willow-crypto` module implementing the NIP-44 v1 payload - (`seal_dm`, `open_dm`). -6. Add a `willow-client` API: `send_dm(recipients: &[EndpointId], content: Content)`. -7. Add an inbox subscription in `willow-client` startup. - -### Adding a new DM content type - -Reuse `willow_messaging::Content` — no new types needed. If a DM- -only content variant appears later, add it to `Content` and treat -it identically on the rumor path. +The original threat-model table is omitted from this revision — it +applies to a design we are not shipping. The MLS spec will produce +its own threat model. Key carry-overs: passive observers must learn +no more than "someone sent a DM, of roughly this size, at roughly +this time", and workers must not be able to link wraps to real +sender identities. From 3e33eb377df00e3780d3121dbee4e4e7c528d11a Mon Sep 17 00:00:00 2001 From: Noah Date: Sat, 25 Apr 2026 01:58:18 -0700 Subject: [PATCH 3/3] spec(#218): apply audit findings - round 2 - Replace stale `EventStore` reference with `EventDag` / `ManagedDag` in Open Questions (legacy trait has been removed from willow-state). - Reword multi-device section: "peer ID" -> "endpoint ID" to match the codebase's `EndpointId` terminology. - Update Appendix A.1 rumor field name from `author_peer_id` to `author_endpoint_id` and call out that Willow uses `EndpointId`. - Footnote Appendix A.2 NIP-44 pseudocode with the real helper names in willow-crypto: `identity_to_x25519` and `ed25519_public_to_x25519`. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/specs/2026-04-24-seal-gift-wrap-dms.md | 22 ++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/docs/specs/2026-04-24-seal-gift-wrap-dms.md b/docs/specs/2026-04-24-seal-gift-wrap-dms.md index 2e1d4de4..47eb6928 100644 --- a/docs/specs/2026-04-24-seal-gift-wrap-dms.md +++ b/docs/specs/2026-04-24-seal-gift-wrap-dms.md @@ -115,8 +115,8 @@ external test vector. ### Multi-device must be designed in from day one -Willow currently uses one Ed25519 key for signing, peer ID, and (via -conversion) DH. The future MLS spec must split: +Willow currently uses one Ed25519 key for signing, endpoint ID, and +(via conversion) DH. The future MLS spec must split: - A long-term **identity key** that names the user across devices. - Per-device **session keys** that participate in MLS group state. @@ -159,7 +159,8 @@ items to be deferred again. 3. **Library choice.** `openmls` (Rust, RFC 9420 conformant, used by several production deployments) is the leading candidate. Open questions: WASM compatibility, ciphersuite selection, storage - trait fit with our `EventStore` abstraction. + trait fit with our `EventDag` / `ManagedDag` abstractions (the + legacy `EventStore` trait has been removed). 4. **Ciphersuite.** RFC 9420 mandates X25519 + Ed25519 + ChaCha20- Poly1305 + SHA-256 as one valid option, which aligns with @@ -205,10 +206,11 @@ Three layers, mirroring NIP-59: [Gift-wrap payload] (would have been on ephemeral author DAG) ``` -The rumor carried `author_peer_id`, a jittered timestamp hint, and a -`willow_messaging::Content`. The seal wrapped the rumor under an X25519 -shared secret to the recipient. The gift wrap wrapped the seal under -a fresh single-use ephemeral key. +The rumor carried `author_endpoint_id` (Willow uses `EndpointId`, not +`PeerId`), a jittered timestamp hint, and a `willow_messaging::Content`. +The seal wrapped the rumor under an X25519 shared secret to the +recipient. The gift wrap wrapped the seal under a fresh single-use +ephemeral key. This appendix omits the originally-proposed `EventKind::DMSeal` and `EventKind::DMGiftWrap` variants from any implementation framing. @@ -238,6 +240,12 @@ chacha_iv = expanded[32..44] // 12 bytes hmac_key = expanded[44..76] ``` +> Note: `ed25519_to_x25519` above is generic NIP-44 pseudocode. In +> the current Willow codebase the corresponding helpers are +> `willow_crypto::identity_to_x25519` (for an `Identity`'s secret +> key) and `willow_crypto::ed25519_public_to_x25519` (for a public +> key); a future MLS spec should call these by their real names. + Padded plaintext layout: ```