Stale decrypted-target state could leak from DM-A into DM-B on a
channel switch because the cleanup effect never cleared the state
when targets became empty. The FE isDmContentKind was also narrower
than the relay's enforced set, missing KIND_STREAM_MESSAGE_V2.
On the relay side, the 15c ingest gate and command-path enforcement
called get_channel directly instead of the cached latch lookup,
adding an unnecessary DB round-trip on every content event. DB errors
in those paths were also mis-classified as client rejections (400)
rather than server errors (500). The get_accessible_channels query
omitted encryption_activated_at from its SELECT list.
Two comment-only fixes from Thufir's security review of PR #1185:
- ingest.rs: add explicit comment on the channel_id == None arm of the
E2E latch gate explaining why the silent skip is safe (channel-less
events cannot be latched; rejected downstream at insert).
- lib.rs: add comment near get_nsec registration scoping the 'private
key never leaves Rust' property to the nip44_encrypt/decrypt commands
and noting get_nsec as a tracked follow-on hardening item.
- check-file-sizes.mjs: bump lib.rs ceiling (1034 → 1044, +8 comment
lines) and tauri.ts ceiling (1218 → 1231, main grew since rebase).
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
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>
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>
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>
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>
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>