Skip to content

feat: encrypt 2-party DMs end-to-end with a relay-owned latch (Phase 1)#1185

Draft
wpfleger96 wants to merge 12 commits into
mainfrom
duncan/dm-e2e-encryption
Draft

feat: encrypt 2-party DMs end-to-end with a relay-owned latch (Phase 1)#1185
wpfleger96 wants to merge 12 commits into
mainfrom
duncan/dm-e2e-encryption

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator

Phase 1 of hybrid end-to-end encryption for buzz DMs: 2-party pairwise NIP-44, reusing the engram/observer "store ciphertext the relay can't read" pattern.

Important

This PR wires the backend, SDK, and the desktop Rust crypto commands + TS bindings. It does not yet encrypt-on-send or decrypt-on-render in the desktop message pipeline — DMs are not transparently E2E on desktop until the follow-up (Phase 1b). What ships here is the relay-enforced boundary plus the building blocks the FE will call.

The encryption-start boundary (marker integrity)

A new encryption_activated_at latch column on channels (migration 0004) marks a DM as E2E from creation. Instead of a free-standing client marker event (which the relay would have to locate per message via a sibling read, and which a member could backdate), the boundary is relay-owned channel state:

  • Set once, server-side via NOW(), in the create_dm INSERT.
  • Never client-writable: ChannelUpdate has no encryption_activated_at field, so the dynamic update_channel SET clause structurally cannot reach it — compile-time-enforced write-once, not just "no current writer."

So the boundary can't be forged, moved, or backdated.

2-party only

Phase 1 encryption is pairwise NIP-44, which has a single peer only for a 2-party DM. Only 2-party DMs latch; group DMs (3-9) stay plaintext until Phase 2 introduces group keys. Latching a group DM would make it unsendable, since ingest rule 15c rejects every non-ciphertext channel message in a latched channel and the FE has no pairwise way to produce ciphertext every member can read.

Relay enforcement

  • Ingest rule 15c (D2) — in a latched channel, every channel-scoped kind that carries a free-text display body must be NIP-44 v2 ciphertext, or it is rejected fail-visible. The gate is the content-bearing half of the relay's channel-scoped acceptance surface (requires_h_channel_scope): 9, 40002, 40003, 40004, 40005, 40006, 40007, 40008, 40100, 45001, 45003 — so a plaintext stream message, v2 message, edit, pinned, bookmarked, scheduled message, reminder, diff, canvas update, forum post, or forum comment can't land cleartext in a latched DM. The security boundary is any channel-scoped kind with a free-text body, not a CLI or builder enumeration: vote (+/-), NIP-29 membership/admin, and huddle lifecycle carry structured or empty content and stay out; kind:1 is global-only. A drift-guard test enumerates requires_h_channel_scope and asserts every channel-scoped kind is classified as either E2E-gated or explicitly bodyless, so the gate cannot silently drift behind the acceptance surface as new kinds are added (the failure mode that twice left content kinds ungated). The validator is strong: base64 alphabet + decoded length >= 99 + 0x02 version byte (a long plaintext in the length envelope is rejected, which the length-only check missed). Enforcement is latch-presence only — it deliberately does not compare the event's created_at against the latch. A created_at >= latch comparator would be backdateable: the relay clamps created_at to +/- 15 min of server time, and proxy:submit events skip that clamp entirely, so a backdated timestamp could otherwise smuggle plaintext below the latch. The timestamp comparison belongs to the read/render path, where untrusted client time is harmless. On a genuine DB lookup error the message is rejected (never silently stored as plaintext); it falls through only on ChannelNotFound, which is handled downstream at insert.
  • Latch-keyed index/workflow skip (D1)dispatch_persistent_event gates both the search-index skip and the workflow-trigger skip on the encryption latch (encryption_activated_at.is_some()), fail-closed (a latch lookup failure is treated as encrypted). The latch — not channel visibility — is the encryption boundary: a private group channel is plaintext (no group key in Phase 1), so keying on it keeps access-controlled search/workflows working for private-group members (search is already membership-gated at query time) while still keeping latched-DM ciphertext out of Typesense and out of content-matching workflow rules. The lookup is cached per channel (the latch is write-once at creation, so the cache can never go stale).

SDK and desktop building blocks

  • build_dm_message NIP-44-encrypts the body to the peer and keeps mention/thread tags in cleartext (shares the message_tags helper with build_message).
  • Desktop nip44_encrypt_to_peer / nip44_decrypt_from_peer Tauri commands — the private key never leaves Rust; the frontend sends plaintext + peer pubkey and gets ciphertext back. TS bindings and e2eBridge passthrough mocks included.

@wpfleger96 wpfleger96 marked this pull request as draft June 23, 2026 14:42
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 and others added 11 commits June 23, 2026 21:01
Phase 1 of hybrid E2E encryption for DMs: 2-party pairwise NIP-44, reusing
the engram/observer "store ciphertext the relay can't read" pattern.

A new `encryption_activated_at` latch column on channels (migration 0004)
marks a DM as E2E from creation. It is relay-owned and write-once at the
`create_dm` INSERT -- `ChannelUpdate` has no such field, so the dynamic
update path structurally cannot move or clear it, making the encryption-start
boundary tamper-evident by construction. Only 2-party DMs latch; group DMs
(3-9) stay plaintext until Phase 2 brings group keys, since pairwise NIP-44
has no single peer to encrypt to.

Ingest rule 15c enforces the boundary fail-visible: a latched channel rejects
any kind:9 that is not NIP-44 v2 ciphertext (strong validator: base64 +
decoded-len >= 99 + 0x02 version byte). Enforcement is latch-PRESENCE only --
no `created_at` comparison -- so a backdated timestamp (drift window or the
clamp-exempt proxy:submit path) cannot smuggle plaintext below the latch.
Dispatch skips search indexing and workflow triggers for private/DM channels
(fail-closed) so ciphertext never reaches Typesense.

Desktop gains `nip44_encrypt_to_peer`/`nip44_decrypt_from_peer` Tauri
commands (private key stays in Rust) plus TS bindings. Encrypt-on-send and
decrypt-on-render in the message pipeline are a follow-up (Phase 1b).

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…D1 on the latch

Rule 15c gated only kind:9, so a plaintext edit (40003), v2 message
(40002), diff (40008), or forum comment (45003) could land in a latched
DM and be stored cleartext — the exact leak the relay-owned latch exists
to prevent. Gate the full channel-message-kind set the CLI recognizes.

D1's index/workflow skip keyed on visibility=="private", but a private
GROUP channel has no group key in Phase 1 and is plaintext. The latch
(encryption_activated_at), not visibility, is the encryption boundary, so
key the skip on it — restoring access-controlled search/workflows for
private-group members (search is already membership-gated at query time)
while still skipping genuinely-encrypted DMs.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The 15c latch guard covered the CLI channel-message set (9/40002/40003/
40008/45003) but not forum posts (45001) or canvas updates (40100). Both
carry up to 64KB of free-text body and an arbitrary h-tag, so a client
can address one at a latched 2-party DM channel and the relay stores it
cleartext — the same plaintext-leak class the latch exists to close. The
security boundary is any channel-scoped kind with a free-text body, not
the CLI's kind enumeration; vote/reaction/deletion/membership stay out as
structured- or empty-content kinds.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The 15c latch gate was a hand-maintained kind list running parallel to
the relay's actual channel-scoped acceptance surface (requires_h_channel_
scope), and the two drifted: 40004-40007 (pinned/bookmarked/scheduled/
reminder) were accepted into a latched DM but never gated, so a plaintext
scheduled message or reminder body would be stored cleartext — the same
leak class already fixed for the edit path.

Gate all four. Pinned (40004) and bookmarked (40005) have no SDK builder
or relay schema constraining their content, so a client can place free
text in the body; with no proof they are bodyless and no legitimate
plaintext producer to break, fail-visible rejection is the safe default.

Add a drift-guard test that enumerates requires_h_channel_scope over the
kind space and asserts every channel-scoped kind is classified as either
E2E-gated (free-text body) or explicitly bodyless. A new channel-scoped
kind added without classification now fails the test, so the gate cannot
silently drift behind the acceptance surface again.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
… FE)

Buzz DM bodies were sent plaintext from the desktop client; the relay's
ciphertext latch now requires NIP-44 v2 for every content kind in a latched
DM. The renderer (formatTimelineMessages) is synchronous and reads
event.content directly, so decryption cannot live there — it happens at the
async cache-population boundary (Option A), keeping the cache plaintext-only
so dedup, overlays, and the renderer all see plaintext for free.

Encrypts the body once before the REST/WS branch on send and in the edit
mutation, scoped to channelType==dm with exactly one non-self participant.
The WS send path overrides the returned event content back to plaintext so
the optimistic-match re-key compares plaintext on both sides. The
fail-visible placeholder substitutes only when valid v2 ciphertext fails to
decrypt — legacy plaintext is shape-checked and passed through untouched.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…phertext leak

On a cold start where channels resolve from warm cache before the identity
IPC resolves, ChannelScreen mounts with selfPubkey undefined. The DM history
query then fetches and caches raw ciphertext via the no-op decryptor, and
because the query key did not include selfPubkey, the later identity-resolved
query landed in the same cache bucket and never refetched — rendering raw v2
ciphertext for up to the 5-minute staleTime, bypassing the fail-visible
placeholder.

Make selfPubkey (lowercased, nullable) the third element of channelMessagesKey
so identity resolution produces a distinct key that forces a refetch and
re-decrypt, and isolates one identity's decrypted DM bodies from another. All
12 call sites thread selfPubkey through so no half-migrated 2-element key splits
the cache. The subscription effect re-runs on selfPubkey so the live sub
re-establishes against the resolved decryptor. The edit path now encrypts and
caches content.trim() to match the send path's wire/cache convention.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…leak

Two cache-population paths bypassed makeDmIngestDecryptor and wrote raw
NIP-44 v2 ciphertext into the rendered DM timeline bucket, the same leak
class as the identity-load cold-start race.

useLoadMissingAncestors fetched a missing thread ancestor and merged it
raw — deterministically reachable by deep-linking to a reply whose
parent is older than the window. useLiveChannelUpdates' dual-write (a
belt-and-suspenders against the useChannelSubscription connect window,
PR #410) merged the raw live event; on an id collision the last writer
wins, so a raw event arriving after the decrypting path could clobber
the decrypted copy with ciphertext until the 5-min staleTime.

Both now route the event through makeDmIngestDecryptor before merge —
a no-op outside a 2-party DM, so uniform across channel types. The
dual-write is kept (option a) rather than dropped (option b) because
its connect-window race protection is real coverage the decrypting
subscription does not provide during that window.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
… timeline

Deep-link, thread-ancestor, and search-hit targets fetched by
ChannelRouteScreen land in targetMessageEvents as RAW RelayEvents and
ChannelScreen's resolvedMessages memo merges them into the rendered
list with no decrypt. For a DM the body is NIP-44 v2 ciphertext, so it
rendered garbled and, on an id collision, clobbered the decrypted cache
copy (the merge keeps the last writer). A new useDecryptedTargetMessageEvents
hook is the single choke point downstream of all three target setters
(the mount-seed useState initializer is synchronous and cannot decrypt
in place), decrypting via makeDmIngestDecryptor before the merge; non-DM
targets pass through synchronously to avoid a held-back first paint.

Also resets the requestedAncestorIdsRef dedup in useLoadMissingAncestors
on selfPubkey change, not just channel change: a cold-start ancestor
fetched while identity is undefined was recorded as done, so after
identity resolved the effect skipped re-fetching it into the live
[...,pubkey] bucket and the ancestor silently went missing from the
thread.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
useDecryptedTargetMessageEvents keyed its render disposition on
dmPeerPubkey, which is null until selfPubkey resolves. A real 2-party
DM during the cold-start pre-identity window therefore took the raw
targetMessageEvents passthrough, rendering NIP-44 v2 ciphertext on
first paint and clobbering the decrypted cache copy on id collision.
Unlike the cache path, these targets are component state merged
directly onto the rendered timeline with no [...,null] vs [...,pubkey]
bucket-orphaning to discard the raw write.

Key the hold-back on channel shape (2-party DM) instead of the
peer pubkey so a DM holds its targets back until decrypt runs,
including before identity is known. Group DMs and non-DM channels
are not peer-encrypted and still pass through synchronously.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Command-kind events (WORKFLOW_DEF/TRIGGER, APPROVAL_GRANT/DENY) short-circuit at is_command_kind before the 15c ciphertext gate, so plaintext YAML, trigger inputs, and approval notes could be persisted into a latched (encryption_activated_at-set) DM channel — defeating the latch. The drift guard keyed off the narrow requires_h_channel_scope proxy, letting command kinds escape classification.

Add a body-shape latch check (empty or NIP-44 v2, else reject fail-visible) at the command path, mirroring the 15c gate's invariant. The rule is kind-agnostic, so it cannot drift and catches plaintext smuggled into nominally-structured kinds. Repoint e2e_drift_guard to the real acceptance surface (required_scope_for_kind().is_ok() && !is_global_only_kind()) with a 4-bucket exactly-one classification.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
enforce_latched_approval_note swallowed every get_workflow error via
.ok(), collapsing a transient DB error to None and skipping the latch
check. A PgPool blip during channel resolution would let a plaintext
approval note reach a latched DM — the leak this PR closes, reopened on
the security boundary itself. Match enforce_latched_body's posture:
NotFound passes (no resolvable channel, cannot be latched), any other
DbError is fail-visible.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96 wpfleger96 force-pushed the duncan/dm-e2e-encryption branch from ae403fa to d3f8c0f Compare June 24, 2026 01:06
The nip44_encrypt_to_peer / nip44_decrypt_from_peer commands ran the
CPU-bound NIP-44 encrypt/decrypt synchronously while holding the keys
lock on the main thread. #1222 already moved the equivalent *_self
commands off-thread via async + spawn_blocking; these DM commands
predate that change and were left on the asymmetric path. Mirror the
*_self template so the hottest DM paths (encrypt-on-send,
decrypt-on-render) no longer block the main thread. Callers are
unchanged — both already await through invokeTauri.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
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.

1 participant