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>
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>
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>
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>
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>
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>
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>