feat: encrypt 2-party DMs end-to-end with a relay-owned latch (Phase 1)#1185
Draft
wpfleger96 wants to merge 12 commits into
Draft
feat: encrypt 2-party DMs end-to-end with a relay-owned latch (Phase 1)#1185wpfleger96 wants to merge 12 commits into
wpfleger96 wants to merge 12 commits into
Conversation
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>
ae403fa to
d3f8c0f
Compare
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_atlatch column onchannels(migration0004) 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:NOW(), in thecreate_dmINSERT.ChannelUpdatehas noencryption_activated_atfield, so the dynamicupdate_channelSET 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
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:1is global-only. A drift-guard test enumeratesrequires_h_channel_scopeand 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 +0x02version 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'screated_atagainst the latch. Acreated_at >= latchcomparator would be backdateable: the relay clampscreated_atto +/- 15 min of server time, andproxy:submitevents 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 onChannelNotFound, which is handled downstream at insert.dispatch_persistent_eventgates 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: aprivategroup 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_messageNIP-44-encrypts the body to the peer and keeps mention/thread tags in cleartext (shares themessage_tagshelper withbuild_message).nip44_encrypt_to_peer/nip44_decrypt_from_peerTauri commands — the private key never leaves Rust; the frontend sends plaintext + peer pubkey and gets ciphertext back. TS bindings ande2eBridgepassthrough mocks included.