---
id: PRD-0010
title: "M9 — End-to-End Encryption (v2)"
status: draft
owner: "Aaron Roney"
created: 2026-07-03
updated: 2026-07-04

depends_on:
- PRD-0009

principles:
- "The server fans out ciphertext it cannot read; routing metadata (from/channel/target) stays plaintext by design."
- "No false security: until key distribution is authenticated end-to-end, E2E over server-served keys only narrows the threat to key-substitution — document exactly what each phase does and does not protect against."
- "Additive, never breaking: the v1 wire already reserves `Payload::Encrypted(Envelope { ciphertext, key_id })` — E2E lands as a new payload variant negotiation, and plaintext v1 peers keep working."
- "Signing keys sign, agreement keys agree: Ed25519 identity keys are never bent into encryption duty — X25519 agreement keys are enrolled alongside them."

references:
- name: "Conclave design — E2E-ready wire format (§13), v2 E2E sketch (§19)"
  url: "docs/DESIGN.md"
- name: "MLS (RFC 9420) — the group-keying north star named by DESIGN §19"
  url: "https://www.rfc-editor.org/rfc/rfc9420"
- name: "PRD-0007 — server hardening (possession-proof enrollment the key registry builds on)"
  url: ".prds/PRD-0007.md"

acceptance_tests:
- id: uat-001
  name: "A whisper between two E2E-capable sessions round-trips as ciphertext: the server observes only an Envelope (no plaintext), and the recipient decrypts and verifies the sender"
  command: cargo nextest run -E 'test(/e2e_whisper_seal/)'
  uat_status: unverified
- id: uat-002
  name: "A channel message is sealed under the channel key and every current member decrypts it; the server store and fan-out path never see plaintext"
  command: cargo nextest run -E 'test(/e2e_channel_seal/)'
  uat_status: unverified
- id: uat-003
  name: "Membership change rotates the channel key: a member removed by kick/ban/acl-remove cannot decrypt messages sealed after the rotation"
  command: cargo nextest run -E 'test(/e2e_rekey_on_membership/)'
  uat_status: unverified
- id: uat-004
  name: "Mixed-capability interop: an E2E-capable sender falls back per channel policy (plaintext or refuse) when a member lacks agreement keys, and v1 peers are never broken"
  command: cargo nextest run -E 'test(/e2e_interop_fallback/)'
  uat_status: unverified
- id: uat-005
  name: "Key-substitution is at least detectable: agreement keys are signed by the owner's Ed25519 identity key, so a server substituting a key fails signature verification at the peer"
  command: cargo nextest run -E 'test(/e2e_key_binding/)'
  uat_status: unverified

tasks:
- id: T-001
  title: "Enroll X25519 agreement keys alongside Ed25519 identity keys"
  priority: 1
  status: todo
  notes: "Each machine derives an X25519 agreement keypair (distinct from the signing key; derive from the stored seed via HKDF so the keystore stays one file, or store a second seed). The agreement public key is enrolled with the machine record and served on demand (extend the machine record + a KeyLookup protocol op, append-only). Cross-signed: the agreement pubkey is signed by the machine's Ed25519 key so a substituted key is detectable (uat-005). Wire changes are append-only per protocol §13."
- id: T-002
  title: "Whisper E2E: pairwise sealed envelopes"
  priority: 1
  status: todo
  notes: "Sender fetches the target machine's agreement key (T-001), performs X25519 ECDH + HKDF to a symmetric key, seals with ChaCha20-Poly1305 (or AES-GCM — pick with ring/aws-lc availability in mind; ring exposes both aead suites), sends Payload::Encrypted with key_id identifying the (sender, recipient, epoch). Recipient unseals and verifies the sender's identity binding. The bridge injection shows a decrypted body only when authenticity checks pass; otherwise it surfaces the existing '<end-to-end-encrypted payload>' placeholder plus a warning notice."
- id: T-003
  title: "Channel E2E: per-channel group key, wrapped to each member"
  priority: 2
  status: todo
  notes: "DESIGN §19's sketch: a per-channel symmetric key generated by the channel admin's bridge, wrapped to each member's agreement key (the membership table gives the member set), distributed via a new append-only protocol op. Messages seal under the channel key with key_id = (channel, epoch). This is deliberately simpler than MLS (no tree, O(n) rewrap on rotation) — fine at conclave's channel sizes; MLS remains the north star if scale demands it."
- id: T-004
  title: "Rekey on membership change"
  priority: 2
  status: todo
  notes: "Every join/leave/kick/ban/acl-remove bumps the channel epoch: a new key is generated and wrapped to the *current* member set, so a removed member cannot read post-removal traffic (uat-003) and a new member cannot read pre-join traffic (no history exists server-side, so this is nearly free). Handle the rotation race: messages sealed under epoch N-1 arriving after the bump are still decryptable (keep a bounded epoch window)."
- id: T-005
  title: "Capability negotiation + fallback policy"
  priority: 2
  status: todo
  notes: "Not every peer will have agreement keys immediately (old CLI versions, fresh machines). Advertise E2E capability in the handshake (append-only field). Per-channel policy chooses the fallback: `plaintext` (seal when possible, plain otherwise — default for continuity) or `required` (refuse to send to a channel with un-keyed members). Whispers: seal whenever the target has a key; plain otherwise with a notice."
- id: T-006
  title: "Trust-model documentation (threat model deltas)"
  priority: 3
  status: todo
  notes: "Document precisely: v1+TLS protects against network observers but the server sees plaintext; this milestone's E2E removes the server from the plaintext path, but key discovery is still server-mediated — a malicious server can substitute keys at enrollment time (before any cross-signature exists) or serve stale member sets. Cross-signing (T-001) makes substitution detectable for keys enrolled honestly; full trust-on-first-use / out-of-band verification is future work. Update DESIGN §12/§19 and the README."
---

# Summary

Move conclave from TLS-to-a-trusted-server to member-to-member E2E: the server fans out ciphertext
it cannot read. Whispers seal pairwise (X25519 + AEAD); channels seal under a per-channel group key
wrapped to each member and rotated on every membership change. The v1 wire format already reserves
the `Payload::Encrypted` envelope, so this lands additively.

# Problem

In v1 the central server sees every message in plaintext. That is acceptable while the operator is
the only user (you trust your own server), but it is the single biggest trust gap for shared or
third-party-hosted servers: DESIGN §12 explicitly warns "secrets on a server you don't operate."
E2E was designed *for* from day one (§13, §19) but deliberately punted out of v1.

# Goals

1. The server (and its store, logs, and operator) never observe message plaintext.
2. Removed members lose access at the next epoch; new members cannot read the past.
3. Existing v1 peers keep working — capability-negotiated, append-only wire changes only.
4. An honest-enrollment key substituted later by the server is detectable at the peer.

# Technical Approach

Three layers, in dependency order:

1. **Keys (T-001):** every machine gets an X25519 agreement keypair next to its Ed25519 signing
   key, cross-signed by the signing key, enrolled with the machine record, fetchable via a new
   `KeyLookup` op.
2. **Whispers (T-002):** pairwise X25519 ECDH → HKDF → AEAD seal into the reserved `Envelope`.
   Small, self-contained, and immediately useful — ships before channels.
3. **Channels (T-003–T-005):** admin-generated per-channel key wrapped to each member's agreement
   key; epoch bump + rewrap on every membership change; capability negotiation with a per-channel
   fallback policy. Deliberately a simple wrapped-group-key design (O(n) rewrap), not MLS — the
   membership table already gives the member set, and channel sizes are small. MLS (RFC 9420)
   remains the named north star if scale ever demands a ratchet tree.

# Assumptions

- PRD-0007's possession-proof enrollment holds: an enrolled machine key was proven at auth, so the
  Ed25519 → X25519 cross-signature chain is anchored in a possession-proof.
- Channel member sets are small (tens, not thousands) — O(n) rewrap per membership change is fine.
- Channel history (PRD-0013) retains 7 days of payload envelopes **verbatim** — under E2E the
  store holds ciphertext, never plaintext, so goal 1 is unaffected. "New members can't read the
  past" still holds cryptographically (they lack pre-join epoch keys), but it is no longer free:
  members must retain their own epoch keys for the retention window so `catch_up` can decrypt,
  and T-004's decryptable-epoch window must span 7 days, not just in-flight rotation races.

# Constraints

- Wire changes are append-only (protocol §13 versioning rule); v1 `Payload::Plain` peers must
  continue to interoperate under the fallback policy.
- Crypto primitives come from the already-compiled backends (ring / aws-lc-rs) — no new crypto
  dependency without strong justification.
- The local CC ↔ bridge stdio hop stays plaintext (DESIGN §4 non-goal — parent/child pipe).

# References to Code

- `src/protocol.rs` — `Payload::Encrypted(Envelope { ciphertext, key_id })` (reserved envelope).
- `src/identity.rs` — keystore, seed handling (HKDF derivation point for T-001), zeroization.
- `src/store.rs` — machine records (agreement-key column), membership table (the member set).
- `src/server/hub.rs` — membership-change sites for the rekey hooks (join/kick/ban/acl-remove).
- `src/bridge.rs`, `src/bridge/sink.rs` — seal/unseal sites; the encrypted-payload placeholder.

# Non-Goals (MVP)

- MLS itself (tree-based group keying) — named north star, not the first implementation.
- Out-of-band key verification UX (safety numbers, QR) — substitution *detection* only, via
  cross-signing; full verification is future work.
- CC ↔ bridge encryption (DESIGN §19 keeps it explicitly out unless the threat model changes).
- Encrypting presence/membership metadata — the server necessarily sees routing metadata.

# History
(Entries appended during implementation go below this line.)

## 2026-07-03 — Created

Scoped during pre-deploy feature triage: SIGTERM handling and ban persistence shipped immediately;
E2E was assessed as a full milestone (a second key type, group keying with rotation, capability
negotiation, and a trust-model story) rather than a pre-deploy feature, and deliberately deferred
here so it lands designed rather than bolted on. The wire envelope it needs has been reserved since
M1.

## 2026-07-04 — history-retention assumption updated

PRD-0013 shipped 7-day channel history after this draft was written, invalidating the "no
server-side history" assumption. The store retains the payload envelope verbatim (ciphertext
under E2E — deliberately designed for this), so the server-blindness goal is intact, but two
scope consequences for T-004: members must keep their own epoch keys for the retention window
so `catch_up` can decrypt the backlog, and the decryptable-epoch window is 7 days, not just the
in-flight rotation race.
