mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat: encrypt-on-send and decrypt-at-ingest for 2-party DMs (Phase 1b 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>
This commit is contained in:
@@ -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 ?? [];
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<RelayEvent[]>(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<RelayEvent[]>(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<RelayEvent[]>(
|
||||
@@ -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<RelayEvent[]>(
|
||||
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) {
|
||||
|
||||
@@ -84,6 +84,9 @@ export async function backfillAuxForMessages(
|
||||
queryClient: QueryClient,
|
||||
channelId: string,
|
||||
historyEvents: RelayEvent[],
|
||||
decryptBatch: (events: RelayEvent[]) => Promise<RelayEvent[]> = async (
|
||||
events,
|
||||
) => events,
|
||||
): Promise<void> {
|
||||
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<RelayEvent[]>(cacheKey, (current = []) =>
|
||||
sortMessages([...current, ...mergedAuxEvents]),
|
||||
sortMessages([...current, ...decryptedAuxEvents]),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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<Channel, "channelType" | "participantPubkeys">,
|
||||
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<RelayEvent, "kind" | "content">,
|
||||
peerPubkey: string | null,
|
||||
decrypt: (peerPubkey: string, ciphertext: string) => Promise<string>,
|
||||
): Promise<string> {
|
||||
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<Channel, "channelType" | "participantPubkeys"> | null,
|
||||
selfPubkey: string | undefined,
|
||||
): (events: RelayEvent[]) => Promise<RelayEvent[]> {
|
||||
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 };
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -57,6 +57,9 @@ export async function pageOlderMessagesUntilRowFloor(
|
||||
queryClient: QueryClient,
|
||||
channelId: string,
|
||||
shouldContinue: () => boolean,
|
||||
decryptBatch: (events: RelayEvent[]) => Promise<RelayEvent[]> = async (
|
||||
events,
|
||||
) => events,
|
||||
): Promise<PageOlderResult> {
|
||||
const queryKey = channelMessagesKey(channelId);
|
||||
const baseline = queryClient.getQueryData<RelayEvent[]>(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<RelayEvent[]>(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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user