From b7a24f561abec6a08f5a1492f8a2b1a29d2efa34 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 23 Jun 2026 11:41:45 -0400 Subject: [PATCH] feat: encrypt-on-send and decrypt-at-ingest for 2-party DMs (Phase 1b FE) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Signed-off-by: Will Pfleger --- .../features/channels/ui/ChannelScreen.tsx | 21 ++- desktop/src/features/home/ui/HomeView.tsx | 5 +- desktop/src/features/messages/hooks.ts | 97 ++++++++-- .../src/features/messages/lib/auxBackfill.ts | 10 +- .../features/messages/lib/dmCrypto.test.mjs | 175 ++++++++++++++++++ desktop/src/features/messages/lib/dmCrypto.ts | 171 +++++++++++++++++ .../messages/lib/pageOlderMessages.ts | 20 +- .../messages/useFetchOlderMessages.ts | 15 +- 8 files changed, 478 insertions(+), 36 deletions(-) create mode 100644 desktop/src/features/messages/lib/dmCrypto.test.mjs create mode 100644 desktop/src/features/messages/lib/dmCrypto.ts diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 54517ce8d..c3bad8672 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -172,12 +172,18 @@ export function ChannelScreen({ : current; }); }, [activeChannelId, openThreadHeadId]); - const messagesQuery = useChannelMessagesQuery(activeChannel); - useChannelSubscription(activeChannel); + const messagesQuery = useChannelMessagesQuery(activeChannel, currentPubkey); + useChannelSubscription(activeChannel, currentPubkey); const { fetchOlder, hasOlderMessages, isFetchingOlder } = - useFetchOlderMessages(activeChannel); - // Newest top-level message only: opening a channel should clear the timeline - // without clearing unread thread replies. + useFetchOlderMessages(activeChannel, currentPubkey); + // Newest TOP-LEVEL message only. The channel read-marker must clear the + // channel timeline without clearing its threads (NIP-RS Option 1): thread + // replies are kind-9 channel events, so taking the last message outright + // would advance the channel frontier past unread replies and the hierarchical + // effective(thread) = max(thread, channel) would silently clear every thread + // badge on channel entry. Scanning from the end for the last message with no + // reply tag keeps the frontier at the last top-level message, leaving thread + // badges intact until the thread itself is read. const latestActiveMessage = React.useMemo(() => { const messages = messagesQuery.data; if (!messages) return null; @@ -238,7 +244,10 @@ export function ChannelScreen({ ); const toggleReactionMutation = useToggleReactionMutation(); const deleteMessageMutation = useDeleteMessageMutation(activeChannel); - const editMessageMutation = useEditMessageMutation(activeChannel); + const editMessageMutation = useEditMessageMutation( + activeChannel, + currentPubkey, + ); const joinChannelMutation = useJoinChannelMutation(activeChannelId); const resolvedMessages = React.useMemo(() => { const currentMessages = messagesQuery.data ?? []; diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index cf2974c85..016a14bd0 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -185,7 +185,10 @@ export function HomeView({ homeInboxWidthPx > 0 && homeInboxWidthPx < THREAD_PANEL_SINGLE_COLUMN_BREAKPOINT_PX; - const channelMessagesQuery = useChannelMessagesQuery(selectedChannel); + const channelMessagesQuery = useChannelMessagesQuery( + selectedChannel, + currentPubkey, + ); const toggleReactionMutation = useToggleReactionMutation(); const channelMessages = channelMessagesQuery.data; const threadContext = useInboxThreadContext( diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 4151b1b0b..7f9d1cad3 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -24,6 +24,7 @@ import { addReaction, deleteMessage, editMessage, + nip44EncryptToPeer, removeReaction, sendChannelMessage, } from "@/shared/api/tauri"; @@ -32,6 +33,10 @@ import type { Channel, Identity, RelayEvent } from "@/shared/api/types"; // from the on-render overlay. import { applyEditTagOverlay } from "@/features/messages/lib/applyEditTagOverlay.mjs"; import { backfillAuxForMessages } from "@/features/messages/lib/auxBackfill"; +import { + dmPeerPubkey, + makeDmIngestDecryptor, +} from "@/features/messages/lib/dmCrypto"; import { countTopLevelTimelineRows } from "@/features/messages/lib/formatTimelineMessages"; import { MIN_TOP_LEVEL_ROWS_PER_FETCH, @@ -168,9 +173,13 @@ function createOptimisticMessage( }; } -export function useChannelMessagesQuery(channel: Channel | null) { +export function useChannelMessagesQuery( + channel: Channel | null, + selfPubkey?: string, +) { const queryClient = useQueryClient(); const queryKey = channelMessagesKey(channel?.id ?? "none"); + const decryptIngested = makeDmIngestDecryptor(channel, selfPubkey); return useQuery({ enabled: channel !== null && channel.channelType !== "forum", @@ -181,9 +190,11 @@ export function useChannelMessagesQuery(channel: Channel | null) { throw new Error("No channel selected."); } - const history = await relayClient.fetchChannelHistory( - channel.id, - CHANNEL_HISTORY_LIMIT, + const history = await decryptIngested( + await relayClient.fetchChannelHistory( + channel.id, + CHANNEL_HISTORY_LIMIT, + ), ); const currentMessages = queryClient.getQueryData(queryKey) ?? []; @@ -194,7 +205,12 @@ export function useChannelMessagesQuery(channel: Channel | null) { // Paint messages immediately; backfill their reactions/edits/deletions // by `#e` in the background (it self-merges into the same cache key). - void backfillAuxForMessages(queryClient, channel.id, history); + void backfillAuxForMessages( + queryClient, + channel.id, + history, + decryptIngested, + ); // Seed the cache, then — only if the cold window renders thinner than a // normal scroll page — top it up to the same visible-row floor. A @@ -208,6 +224,7 @@ export function useChannelMessagesQuery(channel: Channel | null) { queryClient, channel.id, () => true, + decryptIngested, ); } return queryClient.getQueryData(queryKey) ?? mergedHistory; @@ -217,18 +234,21 @@ export function useChannelMessagesQuery(channel: Channel | null) { }); } -export function useChannelSubscription(channel: Channel | null) { +export function useChannelSubscription( + channel: Channel | null, + selfPubkey?: string, +) { const queryClient = useQueryClient(); const channelId = channel?.id ?? null; const channelType = channel?.channelType ?? null; + const decryptIngested = makeDmIngestDecryptor(channel, selfPubkey); const syncLatestHistory = useEffectEvent(async () => { if (!channelId) { return; } - const history = await relayClient.fetchChannelHistory( - channelId, - CHANNEL_HISTORY_LIMIT, + const history = await decryptIngested( + await relayClient.fetchChannelHistory(channelId, CHANNEL_HISTORY_LIMIT), ); queryClient.setQueryData( @@ -236,22 +256,29 @@ export function useChannelSubscription(channel: Channel | null) { (current = []) => mergeTimelineHistoryMessages(current, history), ); - void backfillAuxForMessages(queryClient, channelId, history); + void backfillAuxForMessages( + queryClient, + channelId, + history, + decryptIngested, + ); }); - const appendMessage = useEffectEvent((event: RelayEvent) => { + const appendMessage = useEffectEvent(async (event: RelayEvent) => { if (!channelId) { return; } + const [decrypted] = await decryptIngested([event]); + queryClient.setQueryData( channelMessagesKey(channelId), - (current = []) => mergeTimelineCacheMessages(current, event), + (current = []) => mergeTimelineCacheMessages(current, decrypted), ); - if (event.kind === KIND_SYSTEM_MESSAGE) { + if (decrypted.kind === KIND_SYSTEM_MESSAGE) { try { - const payload = JSON.parse(event.content) as { type?: string }; + const payload = JSON.parse(decrypted.content) as { type?: string }; if ( payload.type === "member_joined" || payload.type === "member_left" || @@ -293,7 +320,9 @@ export function useChannelSubscription(channel: Channel | null) { relayClient .subscribeToChannel(channelId, (event) => { if (!isDisposed) { - appendMessage(event); + void appendMessage(event).catch((error) => { + console.error("Failed to append channel message", channelId, error); + }); } }) .then((dispose) => { @@ -358,6 +387,16 @@ export function useSendMessageMutation( throw new Error("No identity available for sending messages."); } + // Encrypt the body once, before the REST/WS branch, when this is a + // 2-party DM — both transports send `wireContent`. The plaintext `content` + // is preserved for the optimistic cache copy and the success re-key, so + // the cache holds plaintext (matching the decrypt-at-ingest funnel) while + // the wire and relay only ever see ciphertext. + const peerPubkey = dmPeerPubkey(channel, identity.pubkey); + const wireContent = peerPubkey + ? await nip44EncryptToPeer(peerPubkey, content.trim()) + : content; + // `mediaTags` arrives as the merged outgoing tag set (imeta + NIP-30 // emoji). Split it so each kind goes to its own validated Tauri arg — // emoji tags must NOT ride the imeta-only `media` channel (that gate @@ -378,7 +417,7 @@ export function useSendMessageMutation( ) ?? []; const result = await sendChannelMessage( channel.id, - content, + wireContent, parentEventId ?? null, imetaTags, mentionPubkeys, @@ -429,12 +468,18 @@ export function useSendMessageMutation( }; } - return relayClient.sendMessage( + const result = await relayClient.sendMessage( channel.id, - content, + wireContent, mentionPubkeys ?? [], mentionTags, ); + // For a DM, `result.content` is the ciphertext the relay stored. Override + // it with the plaintext so `onSuccess` re-keys the optimistic copy to a + // plaintext-bodied event — matching the cache invariant the REST branch + // already upholds (it synthesizes `content: content.trim()`). A no-op + // outside DMs, where `wireContent === content`. + return peerPubkey ? { ...result, content: content.trim() } : result; }, onMutate: async ({ content, mentionPubkeys, parentEventId, mediaTags }) => { if (!channel || !identity || channel.channelType === "forum") { @@ -538,7 +583,10 @@ export function useDeleteMessageMutation(channel: Channel | null) { }); } -export function useEditMessageMutation(channel: Channel | null) { +export function useEditMessageMutation( + channel: Channel | null, + selfPubkey?: string, +) { const queryClient = useQueryClient(); return useMutation< @@ -555,13 +603,22 @@ export function useEditMessageMutation(channel: Channel | null) { throw new Error("No channel selected."); } + // Encrypt the edit body for a 2-party DM so the relay's ciphertext gate + // accepts it (a plaintext kind-40003 into a latched DM is rejected). The + // plaintext `content` flows on to the `onSuccess` cache update, keeping + // the cache plaintext-only like the send path and decrypt-at-ingest. + const peerPubkey = dmPeerPubkey(channel, selfPubkey); + const wireContent = peerPubkey + ? await nip44EncryptToPeer(peerPubkey, content) + : content; + // `mediaTags` arrives as the merged outgoing set (imeta + NIP-30 emoji). // Split so each rides its own validated Tauri arg — emoji tags must NOT // go through the imeta-only `mediaTags` channel (the Rust `imeta_tags` // guard rejects any non-imeta prefix), mirroring the send path. const { mediaTags: imetaTags, emojiTags } = splitOutgoingTags(mediaTags); - await editMessage(channel.id, eventId, content, imetaTags, emojiTags); + await editMessage(channel.id, eventId, wireContent, imetaTags, emojiTags); }, onSuccess: (_data, { eventId, content, mediaTags }) => { if (!channel) { diff --git a/desktop/src/features/messages/lib/auxBackfill.ts b/desktop/src/features/messages/lib/auxBackfill.ts index df0272d73..ae7c8153c 100644 --- a/desktop/src/features/messages/lib/auxBackfill.ts +++ b/desktop/src/features/messages/lib/auxBackfill.ts @@ -84,6 +84,9 @@ export async function backfillAuxForMessages( queryClient: QueryClient, channelId: string, historyEvents: RelayEvent[], + decryptBatch: (events: RelayEvent[]) => Promise = async ( + events, + ) => events, ): Promise { const messageIds = collectMessageIdsForAuxBackfill(historyEvents); if (messageIds.length === 0) { @@ -109,8 +112,13 @@ export async function backfillAuxForMessages( return; } + // Edit events (kind 40003) carry an encrypted DM body the renderer overlays + // onto the original message — decrypt them before they reach the cache so + // the overlay shows plaintext. A no-op outside 2-party DMs. + const decryptedAuxEvents = await decryptBatch(mergedAuxEvents); + queryClient.setQueryData(cacheKey, (current = []) => - sortMessages([...current, ...mergedAuxEvents]), + sortMessages([...current, ...decryptedAuxEvents]), ); } catch (error) { console.error( diff --git a/desktop/src/features/messages/lib/dmCrypto.test.mjs b/desktop/src/features/messages/lib/dmCrypto.test.mjs new file mode 100644 index 000000000..622ff09a9 --- /dev/null +++ b/desktop/src/features/messages/lib/dmCrypto.test.mjs @@ -0,0 +1,175 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + UNDECRYPTABLE_DM_PLACEHOLDER, + decryptIngestedContent, + dmPeerPubkey, + looksLikeNip44V2, + makeDmIngestDecryptor, +} from "./dmCrypto.ts"; + +import { + KIND_STREAM_MESSAGE, + KIND_STREAM_MESSAGE_EDIT, +} from "@/shared/constants/kinds"; + +// base64(0x02 + 98 zero bytes) — 99 decoded bytes, the minimal valid NIP-44 v2 +// envelope (1 version + 32 nonce + 32 MAC + 34 ciphertext floor). 132 chars. +const VALID_V2 = + "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; +// Same length and alphabet, but first decoded byte is 0x00, not 0x02. +const WRONG_VERSION = + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + +// ── looksLikeNip44V2 — mirrors relay validate_nip44_v2 envelope check ───────── + +test("looksLikeNip44V2 accepts a minimal valid v2 envelope", () => { + assert.equal(looksLikeNip44V2(VALID_V2), true); +}); + +test("looksLikeNip44V2 rejects content with wrong version prefix", () => { + assert.equal(looksLikeNip44V2(WRONG_VERSION), false); +}); + +test("looksLikeNip44V2 rejects content shorter than 99 decoded bytes", () => { + assert.equal(looksLikeNip44V2("AgAAAAAAAAAAAAA="), false); +}); + +test("looksLikeNip44V2 rejects empty content", () => { + assert.equal(looksLikeNip44V2(""), false); +}); + +test("looksLikeNip44V2 rejects plaintext that isn't base64-shaped", () => { + assert.equal(looksLikeNip44V2("hey, lunch at noon?"), false); +}); + +test("looksLikeNip44V2 rejects base64 with a non-trailing pad char", () => { + // '=' before the last two positions is malformed padding. + const bad = `Ag==${VALID_V2.slice(4)}`; + assert.equal(looksLikeNip44V2(bad), false); +}); + +// ── AC1: decryptIngestedContent — valid-v2-undecryptable vs malformed ───────── + +const peer = "a".repeat(64); +const throwingDecrypt = async () => { + throw new Error("nip44 decrypt failed: hmac mismatch"); +}; +const okDecrypt = async (_peer, ciphertext) => + `decrypted(${ciphertext.slice(0, 4)})`; + +test("decryptIngestedContent substitutes placeholder when valid v2 ciphertext fails to decrypt", async () => { + const body = await decryptIngestedContent( + { kind: KIND_STREAM_MESSAGE, content: VALID_V2 }, + peer, + throwingDecrypt, + ); + assert.equal(body, UNDECRYPTABLE_DM_PLACEHOLDER); +}); + +test("decryptIngestedContent passes legacy plaintext through unchanged on decrypt failure", async () => { + // The critical distinction: malformed / never-encrypted content must NOT + // become the placeholder. It is not v2-shaped, so decrypt is never attempted. + const body = await decryptIngestedContent( + { kind: KIND_STREAM_MESSAGE, content: "hey, lunch at noon?" }, + peer, + throwingDecrypt, + ); + assert.equal(body, "hey, lunch at noon?"); +}); + +test("decryptIngestedContent returns plaintext when valid v2 ciphertext decrypts", async () => { + const body = await decryptIngestedContent( + { kind: KIND_STREAM_MESSAGE, content: VALID_V2 }, + peer, + okDecrypt, + ); + assert.equal(body, "decrypted(AgAA)"); +}); + +test("decryptIngestedContent decrypts edit-kind content (AC2 ingest side)", async () => { + const body = await decryptIngestedContent( + { kind: KIND_STREAM_MESSAGE_EDIT, content: VALID_V2 }, + peer, + okDecrypt, + ); + assert.equal(body, "decrypted(AgAA)"); +}); + +test("decryptIngestedContent skips decrypt when there is no DM peer", async () => { + const body = await decryptIngestedContent( + { kind: KIND_STREAM_MESSAGE, content: VALID_V2 }, + null, + throwingDecrypt, + ); + assert.equal(body, VALID_V2); +}); + +test("decryptIngestedContent does not decrypt non-content kinds (system messages)", async () => { + const KIND_SYSTEM_MESSAGE = 40099; + const body = await decryptIngestedContent( + { kind: KIND_SYSTEM_MESSAGE, content: VALID_V2 }, + peer, + throwingDecrypt, + ); + assert.equal(body, VALID_V2); +}); + +// ── AC3: dmPeerPubkey — peer = participants minus self, 2-party only ────────── + +test("dmPeerPubkey returns the single non-self participant in a 2-party DM", () => { + const channel = { channelType: "dm", participantPubkeys: ["SELF", "PEER"] }; + assert.equal(dmPeerPubkey(channel, "self"), "PEER"); +}); + +test("dmPeerPubkey is case-insensitive on self matching", () => { + const channel = { channelType: "dm", participantPubkeys: ["AbC", "def"] }; + assert.equal(dmPeerPubkey(channel, "abc"), "def"); +}); + +test("dmPeerPubkey returns null for a group DM with more than one peer", () => { + const channel = { + channelType: "dm", + participantPubkeys: ["self", "p1", "p2"], + }; + assert.equal(dmPeerPubkey(channel, "self"), null); +}); + +test("dmPeerPubkey returns null for non-DM channels", () => { + const channel = { + channelType: "stream", + participantPubkeys: ["self", "peer"], + }; + assert.equal(dmPeerPubkey(channel, "self"), null); +}); + +test("dmPeerPubkey returns null when self pubkey is unknown", () => { + const channel = { channelType: "dm", participantPubkeys: ["a", "b"] }; + assert.equal(dmPeerPubkey(channel, undefined), null); +}); + +// ── makeDmIngestDecryptor — no-op outside a 2-party DM ──────────────────────── + +test("makeDmIngestDecryptor returns events untouched outside a 2-party DM", async () => { + const events = [ + { kind: KIND_STREAM_MESSAGE, content: VALID_V2 }, + { kind: KIND_STREAM_MESSAGE, content: "plaintext" }, + ]; + // A stream channel has no DM peer, so the decryptor must be a pure identity + // pass-through — it never touches the IPC primitive, so this stays + // deterministic without a Tauri mock. + const decrypt = makeDmIngestDecryptor( + { channelType: "stream", participantPubkeys: ["self", "peer"] }, + "self", + ); + const result = await decrypt(events); + assert.equal(result, events); +}); + +test("makeDmIngestDecryptor returns events untouched when channel is null", async () => { + const events = [{ kind: KIND_STREAM_MESSAGE, content: VALID_V2 }]; + const decrypt = makeDmIngestDecryptor(null, "self"); + const result = await decrypt(events); + assert.equal(result, events); +}); diff --git a/desktop/src/features/messages/lib/dmCrypto.ts b/desktop/src/features/messages/lib/dmCrypto.ts new file mode 100644 index 000000000..9b7388516 --- /dev/null +++ b/desktop/src/features/messages/lib/dmCrypto.ts @@ -0,0 +1,171 @@ +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { + KIND_STREAM_MESSAGE, + KIND_STREAM_MESSAGE_EDIT, +} from "@/shared/constants/kinds"; +import { nip44DecryptFromPeer } from "@/shared/api/tauri"; +import type { Channel, RelayEvent } from "@/shared/api/types"; + +/** + * Body shown when a DM message is valid NIP-44 v2 ciphertext we cannot decrypt + * (peer used a key/version we can't read) — fail-visible, never blank or + * garbled. Substituted ONLY for undecryptable valid-v2 content; malformed or + * legacy-plaintext content is passed through untouched (see + * `decryptIngestedContent`). + */ +export const UNDECRYPTABLE_DM_PLACEHOLDER = + "[encrypted message — update your client to read it]"; + +/** + * Kinds that carry a user-authored free-text body in a DM and must therefore be + * encrypted on send and decrypted at ingest. System messages, reactions, and + * deletions carry no peer-encrypted body and are excluded. + */ +function isDmContentKind(kind: number): boolean { + return kind === KIND_STREAM_MESSAGE || kind === KIND_STREAM_MESSAGE_EDIT; +} + +/** + * The single NIP-44 peer for a 2-party DM: the one participant that isn't us. + * Returns null when the channel isn't a 2-party DM (open/stream channels, + * self-only, or group DMs with >1 other participant), which is exactly the + * scope where FE peer crypto applies — callers skip encrypt/decrypt on null. + */ +export function dmPeerPubkey( + channel: Pick, + selfPubkey: string | undefined, +): string | null { + if (channel.channelType !== "dm" || !selfPubkey) { + return null; + } + const self = normalizePubkey(selfPubkey); + const peers = channel.participantPubkeys.filter( + (pubkey) => normalizePubkey(pubkey) !== self, + ); + return peers.length === 1 ? peers[0] : null; +} + +/** + * Whether `content` is a syntactically plausible NIP-44 v2 ciphertext payload. + * + * Mirrors the relay's `buzz_core::observer::validate_nip44_v2` envelope check + * exactly so the FE's "this IS encrypted, we just can't read it" judgement + * matches the boundary the relay enforces: + * - standard base64 alphabet, padding only at the end, length a multiple of 4 + * - decoded length >= 99 bytes (1 version + 32 nonce + 32 MAC + >=34 ciphertext) + * - first decoded byte is 0x02 (NIP-44 version 2) + * + * It is an envelope check, not decryption: a `true` result means the content is + * shaped like v2 ciphertext, so a decrypt failure is "valid ciphertext we can't + * read" (→ placeholder) rather than "this was never encrypted" (→ pass through). + */ +export function looksLikeNip44V2(content: string): boolean { + const len = content.length; + if (len === 0 || len % 4 !== 0) { + return false; + } + + let padCount = 0; + for (let i = 0; i < len; i++) { + const c = content[i]; + if (c === "=") { + // Padding is only legal in the final two positions. + if (i < len - 2) { + return false; + } + padCount++; + if (padCount > 2) { + return false; + } + } else if (/[A-Za-z0-9+/]/.test(c)) { + // A base64 char after padding has begun is malformed. + if (padCount > 0) { + return false; + } + } else { + return false; + } + } + + const decodedLen = (len / 4) * 3 - padCount; + if (decodedLen < 99) { + return false; + } + + const b64Val = (c: string): number => { + const code = c.charCodeAt(0); + if (code >= 65 && code <= 90) return code - 65; // A-Z + if (code >= 97 && code <= 122) return code - 97 + 26; // a-z + if (code >= 48 && code <= 57) return code - 48 + 52; // 0-9 + if (c === "+") return 62; + if (c === "/") return 63; + return -1; + }; + + const firstByte = (b64Val(content[0]) << 2) | (b64Val(content[1]) >> 4); + return firstByte === 0x02; +} + +/** + * Decrypt one ingested DM event's content to plaintext for the cache. + * + * Decrypt is attempted only for 2-party-DM content kinds whose content is + * shaped like NIP-44 v2 ciphertext (`looksLikeNip44V2`). Everything else — + * non-DM channels, system/reaction/deletion kinds, and legacy plaintext that + * predates encryption — is returned unchanged. + * + * When the content IS valid v2 ciphertext but `decrypt` throws (peer key/version + * we can't read), the fail-visible placeholder is substituted. This is the AC1 + * distinction: a decrypt failure on valid-v2 content shows the placeholder, + * while content that was never v2-shaped is passed through as-is. + */ +export async function decryptIngestedContent( + event: Pick, + peerPubkey: string | null, + decrypt: (peerPubkey: string, ciphertext: string) => Promise, +): Promise { + if ( + peerPubkey === null || + !isDmContentKind(event.kind) || + !looksLikeNip44V2(event.content) + ) { + return event.content; + } + + try { + return await decrypt(peerPubkey, event.content); + } catch { + return UNDECRYPTABLE_DM_PLACEHOLDER; + } +} + +/** + * Build the decrypt-at-ingest mapper for a channel: it decrypts encrypted + * 2-party-DM bodies to plaintext (via the real Tauri NIP-44 peer primitive) and + * leaves everything else — non-DM channels, non-content kinds, legacy plaintext + * — untouched. Returns a no-op identity mapper outside a 2-party DM, so every + * ingest site can call it unconditionally without branching on channel type. + * + * Callers pass this to the cache-population paths (history fetch, scrollback, + * aux backfill, live append) so the cache only ever holds plaintext content. + */ +export function makeDmIngestDecryptor( + channel: Pick | null, + selfPubkey: string | undefined, +): (events: RelayEvent[]) => Promise { + const peerPubkey = channel ? dmPeerPubkey(channel, selfPubkey) : null; + if (peerPubkey === null) { + return (events) => Promise.resolve(events); + } + return (events) => + Promise.all( + events.map(async (event) => { + const content = await decryptIngestedContent( + event, + peerPubkey, + nip44DecryptFromPeer, + ); + return content === event.content ? event : { ...event, content }; + }), + ); +} diff --git a/desktop/src/features/messages/lib/pageOlderMessages.ts b/desktop/src/features/messages/lib/pageOlderMessages.ts index 47fdaf00e..0d85ad65e 100644 --- a/desktop/src/features/messages/lib/pageOlderMessages.ts +++ b/desktop/src/features/messages/lib/pageOlderMessages.ts @@ -57,6 +57,9 @@ export async function pageOlderMessagesUntilRowFloor( queryClient: QueryClient, channelId: string, shouldContinue: () => boolean, + decryptBatch: (events: RelayEvent[]) => Promise = async ( + events, + ) => events, ): Promise { const queryKey = channelMessagesKey(channelId); const baseline = queryClient.getQueryData(queryKey) ?? []; @@ -78,10 +81,12 @@ export async function pageOlderMessagesUntilRowFloor( // sortMessages dedupes by id. Subtracting 1 risks skipping same-second // messages. const oldestTimestamp = before[0].created_at; - const olderMessages = await relayClient.fetchChannelHistoryBefore( - channelId, - oldestTimestamp, - OLDER_MESSAGES_BATCH_SIZE, + const olderMessages = await decryptBatch( + await relayClient.fetchChannelHistoryBefore( + channelId, + oldestTimestamp, + OLDER_MESSAGES_BATCH_SIZE, + ), ); batchesFetched += 1; @@ -100,7 +105,12 @@ export async function pageOlderMessagesUntilRowFloor( queryClient.setQueryData(queryKey, (current = []) => mergeTimelineHistoryMessages(current, olderMessages), ); - void backfillAuxForMessages(queryClient, channelId, olderMessages); + void backfillAuxForMessages( + queryClient, + channelId, + olderMessages, + decryptBatch, + ); } // Progress guard, not exhaustion: if the oldest timestamp didn't move back diff --git a/desktop/src/features/messages/useFetchOlderMessages.ts b/desktop/src/features/messages/useFetchOlderMessages.ts index 50ad7ca9d..632d9bbb7 100644 --- a/desktop/src/features/messages/useFetchOlderMessages.ts +++ b/desktop/src/features/messages/useFetchOlderMessages.ts @@ -1,13 +1,21 @@ -import { useCallback, useRef, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { channelMessagesKey } from "@/features/messages/lib/messageQueryKeys"; +import { makeDmIngestDecryptor } from "@/features/messages/lib/dmCrypto"; import { pageOlderMessagesUntilRowFloor } from "@/features/messages/lib/pageOlderMessages"; import type { Channel, RelayEvent } from "@/shared/api/types"; -export function useFetchOlderMessages(channel: Channel | null) { +export function useFetchOlderMessages( + channel: Channel | null, + selfPubkey?: string, +) { const queryClient = useQueryClient(); const channelId = channel?.id ?? null; + const decryptIngested = useMemo( + () => makeDmIngestDecryptor(channel, selfPubkey), + [channel, selfPubkey], + ); const [isFetchingOlder, setIsFetchingOlder] = useState(false); const [hasOlderMessages, setHasOlderMessages] = useState(true); const isFetchingOlderRef = useRef(false); @@ -45,6 +53,7 @@ export function useFetchOlderMessages(channel: Channel | null) { queryClient, channelId, () => previousChannelIdRef.current === channelId, + decryptIngested, ); if (!more) { hasOlderMessagesRef.current = false; @@ -56,7 +65,7 @@ export function useFetchOlderMessages(channel: Channel | null) { isFetchingOlderRef.current = false; setIsFetchingOlder(false); } - }, [channelId, queryClient]); + }, [channelId, queryClient, decryptIngested]); return { fetchOlder, isFetchingOlder, hasOlderMessages }; }