From cd3c3824b9c78dc26f661da4e1e1f85346874c1a Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 30 Oct 2025 11:17:39 -0400 Subject: [PATCH] Handle dual NIP-44/NIP-04 watch history encryption --- docs/nostr-event-schemas.md | 11 +- js/nostr/watchHistory.js | 798 +++++++++++++++++++++++++++-------- js/nostrEventSchemas.js | 24 +- tests/watch-history.test.mjs | 367 ++++++++++++---- 4 files changed, 930 insertions(+), 270 deletions(-) diff --git a/docs/nostr-event-schemas.md b/docs/nostr-event-schemas.md index bcf792e7..f96f253e 100644 --- a/docs/nostr-event-schemas.md +++ b/docs/nostr-event-schemas.md @@ -210,10 +210,13 @@ Active identifiers include the default `WATCH_HISTORY_LIST_IDENTIFIER` `WATCH_HISTORY_LEGACY_LIST_IDENTIFIERS`. Chunk events derive their `d` tag from `:`, advertise `['snapshot', ]`, and carry `['chunk', , ]` plus an optional leading `['head','1']` marker so relays can prioritize the first -ciphertext. All chunk content is encrypted with NIP-04 and stores only pointer -entries; richer metadata remains on-device via the -[`WatchHistoryService`](../js/watchHistoryService.js) APIs, which default to -pointer-only writes and local-only metadata caches.【F:config/instance-config.js†L60-L78】【F:js/nostrEventSchemas.js†L157-L189】【F:js/nostr/watchHistory.js†L1343-L1400】【F:js/watchHistoryService.js†L331-L376】 +ciphertext. Chunk content is encrypted with the strongest mutually supported +scheme: clients probe for NIP-44 v2 first, fall back to NIP-44, and finally use +NIP-04 for legacy compatibility. The negotiated value is written back to the +`['encrypted', ...]` tag so other readers can attempt the same scheme before +falling back. All payloads store only pointer entries; richer metadata remains +on-device via the [`WatchHistoryService`](../js/watchHistoryService.js) APIs, +which default to pointer-only writes and local-only metadata caches.【F:config/instance-config.js†L60-L78】【F:js/nostrEventSchemas.js†L157-L189】【F:js/nostr/watchHistory.js†L1380-L1549】【F:js/watchHistoryService.js†L331-L376】 Refer to the [`WatchHistoryService`](../js/watchHistoryService.js) for queue management hooks, manual snapshot helpers, and metadata toggle controls that diff --git a/js/nostr/watchHistory.js b/js/nostr/watchHistory.js index d8878e60..8529bee1 100644 --- a/js/nostr/watchHistory.js +++ b/js/nostr/watchHistory.js @@ -34,6 +34,11 @@ const WATCH_HISTORY_REPUBLISH_BASE_DELAY_MS = 2000; const WATCH_HISTORY_REPUBLISH_MAX_DELAY_MS = 5 * 60 * 1000; const WATCH_HISTORY_REPUBLISH_MAX_ATTEMPTS = 8; const WATCH_HISTORY_REPUBLISH_JITTER = 0.25; +const WATCH_HISTORY_ENCRYPTION_FALLBACK_ORDER = Object.freeze([ + "nip44_v2", + "nip44", + "nip04", +]); function cloneVideoMetadata(video) { if (!video || typeof video !== "object") { @@ -657,6 +662,239 @@ function looksLikeJsonStructure(content) { return first === "{" || first === "["; } +function hexToBytesCompat(hex, tools = null) { + if (typeof hex !== "string") { + throw new Error("Invalid hex input."); + } + const trimmed = hex.trim(); + if (!trimmed || trimmed.length % 2 !== 0) { + throw new Error("Invalid hex input."); + } + if (tools?.utils && typeof tools.utils.hexToBytes === "function") { + return tools.utils.hexToBytes(trimmed); + } + const bytes = new Uint8Array(trimmed.length / 2); + for (let index = 0; index < trimmed.length; index += 2) { + const byte = Number.parseInt(trimmed.slice(index, index + 2), 16); + if (Number.isNaN(byte)) { + throw new Error("Invalid hex input."); + } + bytes[index / 2] = byte; + } + return bytes; +} + +function createNip44CipherSuite(tools, privateKeyHex, targetPubkeyHex) { + if (!tools || !privateKeyHex || !targetPubkeyHex) { + return null; + } + const normalizedPrivateKey = + typeof privateKeyHex === "string" && privateKeyHex.trim() + ? privateKeyHex.trim().toLowerCase() + : ""; + const normalizedTarget = + typeof targetPubkeyHex === "string" && targetPubkeyHex.trim() + ? targetPubkeyHex.trim().toLowerCase() + : ""; + if (!normalizedPrivateKey || !normalizedTarget) { + return null; + } + + let privateKeyBytes; + try { + privateKeyBytes = hexToBytesCompat(normalizedPrivateKey, tools); + } catch (_) { + return null; + } + + const nip44 = tools?.nip44 && typeof tools.nip44 === "object" ? tools.nip44 : null; + const suite = {}; + + const nip44v2 = nip44?.v2 && typeof nip44.v2 === "object" ? nip44.v2 : null; + if ( + nip44v2 && + typeof nip44v2.encrypt === "function" && + typeof nip44v2.decrypt === "function" && + typeof nip44v2?.utils?.getConversationKey === "function" + ) { + let cachedKey = null; + const ensureKey = () => { + if (!cachedKey) { + cachedKey = nip44v2.utils.getConversationKey(privateKeyBytes, normalizedTarget); + } + return cachedKey; + }; + suite.v2 = { + encrypt: (plaintext) => nip44v2.encrypt(plaintext, ensureKey()), + decrypt: (ciphertext) => nip44v2.decrypt(ciphertext, ensureKey()), + }; + } + + const legacyGetConversationKey = (() => { + if (typeof nip44?.getConversationKey === "function") { + return nip44.getConversationKey.bind(nip44); + } + if (typeof nip44?.utils?.getConversationKey === "function") { + return nip44.utils.getConversationKey.bind(nip44.utils); + } + return null; + })(); + + if ( + typeof nip44?.encrypt === "function" && + typeof nip44?.decrypt === "function" && + typeof legacyGetConversationKey === "function" + ) { + let cachedKey = null; + const ensureLegacyKey = () => { + if (!cachedKey) { + cachedKey = legacyGetConversationKey(privateKeyBytes, normalizedTarget); + } + return cachedKey; + }; + suite.legacy = { + encrypt: (plaintext) => nip44.encrypt(plaintext, ensureLegacyKey()), + decrypt: (ciphertext) => nip44.decrypt(ciphertext, ensureLegacyKey()), + }; + } + + if (!suite.v2 && !suite.legacy) { + return null; + } + + return suite; +} + +function normalizeEncryptionTagValue(value) { + if (typeof value !== "string") { + return ""; + } + const normalized = value.trim().toLowerCase(); + switch (normalized) { + case "nip04": + case "nip-04": + case "nip4": + return "nip04"; + case "nip44_v2": + case "nip44-v2": + case "nip44v2": + return "nip44_v2"; + case "nip44_v1": + case "nip44-v1": + case "nip44v1": + case "nip44": + return "nip44"; + default: + return ""; + } +} + +function extractWatchHistoryEncryptionHints(event, ciphertext) { + const hints = []; + const pushUnique = (hint) => { + if (hint && !hints.includes(hint)) { + hints.push(hint); + } + }; + + const tags = Array.isArray(event?.tags) ? event.tags : []; + for (const tag of tags) { + if (!Array.isArray(tag) || tag.length < 2) { + continue; + } + const label = typeof tag[0] === "string" ? tag[0].trim().toLowerCase() : ""; + if (label !== "encrypted") { + continue; + } + const normalizedValue = normalizeEncryptionTagValue(tag[1]); + if (normalizedValue) { + pushUnique(normalizedValue); + } + } + + if (!hints.length && isNip04EncryptedWatchHistoryEvent(event, ciphertext)) { + pushUnique("nip04"); + } + + return hints; +} + +function determineWatchHistoryDecryptionOrder( + event, + ciphertext, + availableSchemes = [], +) { + const available = Array.isArray(availableSchemes) ? availableSchemes : []; + const availableSet = new Set(available); + const prioritized = []; + const hints = extractWatchHistoryEncryptionHints(event, ciphertext); + const aliasMap = { + nip04: ["nip04"], + nip44: ["nip44", "nip44_v2"], + nip44_v2: ["nip44_v2", "nip44"], + }; + + for (const hint of hints) { + const candidates = Array.isArray(aliasMap[hint]) ? aliasMap[hint] : [hint]; + for (const candidate of candidates) { + if (availableSet.has(candidate) && !prioritized.includes(candidate)) { + prioritized.push(candidate); + break; + } + } + } + + for (const fallback of WATCH_HISTORY_ENCRYPTION_FALLBACK_ORDER) { + if (availableSet.has(fallback) && !prioritized.includes(fallback)) { + prioritized.push(fallback); + } + } + + return prioritized.length ? prioritized : available; +} + +async function resolveNostrToolkit(deps = {}, cacheRef = null) { + if (cacheRef?.current) { + return cacheRef.current; + } + + const ensureToolkit = + typeof deps.ensureNostrTools === "function" ? deps.ensureNostrTools : ensureNostrTools; + if (ensureToolkit) { + try { + const ensured = await ensureToolkit(); + if (ensured) { + if (cacheRef) { + cacheRef.current = ensured; + } + return ensured; + } + } catch (error) { + devLogger.warn("[nostr] Failed to resolve nostr-tools for watch history:", error); + } + } + + const readCachedToolkit = + typeof deps.getCachedNostrTools === "function" + ? deps.getCachedNostrTools + : getCachedNostrTools; + if (readCachedToolkit) { + try { + const cached = await readCachedToolkit(); + if (cached) { + if (cacheRef) { + cacheRef.current = cached; + } + return cached; + } + } catch (error) { + devLogger.warn("[nostr] Failed to read cached nostr-tools for watch history:", error); + } + } + + return cacheRef?.current || null; +} + function mergeWatchHistoryItemsWithFallback(parsed, fallbackItems) { if (!parsed || typeof parsed !== "object") { return { @@ -716,8 +954,11 @@ export function isNip04EncryptedWatchHistoryEvent(pointerEvent, ciphertext) { return false; } const ivIndex = normalizedCiphertext.indexOf("?iv="); - const baseSegment = ivIndex >= 0 ? normalizedCiphertext.slice(0, ivIndex) : normalizedCiphertext; - const ivSegment = ivIndex >= 0 ? normalizedCiphertext.slice(ivIndex + 4) : ""; + if (ivIndex < 0) { + return false; + } + const baseSegment = normalizedCiphertext.slice(0, ivIndex); + const ivSegment = normalizedCiphertext.slice(ivIndex + 4); const base64Regex = /^[A-Za-z0-9+/]+={0,2}$/; if (!baseSegment || !base64Regex.test(baseSegment)) { return false; @@ -1149,12 +1390,14 @@ class WatchHistoryManager { normalizedLogged === actorKey && signer && typeof signer.signEvent === "function"; - const useActiveSignerEncrypt = - canUseActiveSignerSign && signer && typeof signer.nip04Encrypt === "function"; + const signerSupportsEncryption = + canUseActiveSignerSign && + signer && + (typeof signer.nip04Encrypt === "function" || typeof signer.nip44Encrypt === "function"); const activeSigner = canUseActiveSignerSign ? signer : null; - const encryptionSigner = useActiveSignerEncrypt ? signer : null; + const encryptionSigner = signerSupportsEncryption ? signer : null; if ( - (canUseActiveSignerSign || useActiveSignerEncrypt) && + (canUseActiveSignerSign || signerSupportsEncryption) && this.deps.shouldRequestExtensionPermissions?.(signer) ) { await this.deps.ensureExtensionPermissions?.(DEFAULT_NIP07_PERMISSION_METHODS); @@ -1225,67 +1468,161 @@ class WatchHistoryManager { source: options.source || "unknown", }); const createdAtBase = Math.max(Math.floor(Date.now() / 1000), this.lastCreatedAt + 1); - let cachedNip04Tools = null; - const ensureNip04Tools = async () => { - if (cachedNip04Tools) { - return cachedNip04Tools; + const toolkitCache = { current: null }; + const encryptionCandidates = []; + const registerEncryptor = (scheme, handler, source) => { + if (!scheme || typeof handler !== "function") { + return; } - - const ensureToolkit = - typeof this.deps.ensureNostrTools === "function" - ? this.deps.ensureNostrTools - : ensureNostrTools; - if (ensureToolkit) { - try { - const ensured = await ensureToolkit(); - if ( - ensured?.nip04 && - typeof ensured.nip04.encrypt === "function" - ) { - cachedNip04Tools = ensured; - return cachedNip04Tools; - } - } catch (error) { - devLogger.warn( - "[nostr] Failed to resolve nostr-tools for watch history:", - error, - ); - } - } - - const readCachedToolkit = - typeof this.deps.getCachedNostrTools === "function" - ? this.deps.getCachedNostrTools - : getCachedNostrTools; - if (readCachedToolkit) { - try { - const cached = await readCachedToolkit(); - if ( - cached?.nip04 && - typeof cached.nip04.encrypt === "function" - ) { - cachedNip04Tools = cached; - return cachedNip04Tools; - } - } catch (error) { - devLogger.warn( - "[nostr] Failed to read cached nostr-tools for watch history:", - error, - ); - } - } - - return null; + encryptionCandidates.push({ + scheme, + handler, + source: source || "unknown", + }); }; - const encryptChunk = async (plaintext) => { - if (encryptionSigner) { - return encryptionSigner.nip04Encrypt(actorPubkey, plaintext); + + if (encryptionSigner) { + if (typeof encryptionSigner.nip44Encrypt === "function") { + registerEncryptor( + "nip44_v2", + (plaintext) => encryptionSigner.nip44Encrypt(actorPubkey, plaintext), + "active-signer", + ); + registerEncryptor( + "nip44", + (plaintext) => encryptionSigner.nip44Encrypt(actorPubkey, plaintext), + "active-signer", + ); } - const tools = await ensureNip04Tools(); - if (!tools?.nip04 || typeof tools.nip04.encrypt !== "function") { - throw new Error("nip04-unavailable"); + if (typeof encryptionSigner.nip04Encrypt === "function") { + registerEncryptor( + "nip04", + (plaintext) => encryptionSigner.nip04Encrypt(actorPubkey, plaintext), + "active-signer", + ); } - return tools.nip04.encrypt(privateKey, actorPubkey, plaintext); + } + + const ensureSessionEncryptors = async () => { + if (!privateKey) { + return; + } + const tools = await resolveNostrToolkit(this.deps, toolkitCache); + if (!tools) { + return; + } + const suite = createNip44CipherSuite(tools, privateKey, actorKey); + if (suite?.v2?.encrypt) { + registerEncryptor( + "nip44_v2", + (plaintext) => suite.v2.encrypt(plaintext), + "session-nip44_v2", + ); + registerEncryptor( + "nip44", + (plaintext) => suite.v2.encrypt(plaintext), + "session-nip44_v2", + ); + } + if (suite?.legacy?.encrypt) { + registerEncryptor( + "nip44", + (plaintext) => suite.legacy.encrypt(plaintext), + "session-nip44", + ); + } + if (tools?.nip04 && typeof tools.nip04.encrypt === "function") { + registerEncryptor( + "nip04", + (plaintext) => tools.nip04.encrypt(privateKey, actorPubkey, plaintext), + "session-nip04", + ); + } + }; + + await ensureSessionEncryptors(); + + if (!encryptionCandidates.length) { + userLogger.warn( + "[nostr] No encryption strategies available to publish watch history.", + { + actor: actorKey, + snapshotId, + }, + ); + return { ok: false, error: "encryption-unavailable", retryable: false }; + } + + const candidateIdentity = (candidate) => + `${candidate.scheme}:${candidate.source || "unknown"}`; + const failedEncryptionCandidates = new Set(); + const encryptionAttemptErrors = []; + let selectedEncryptor = null; + + const tryEncryptWithCandidate = async (candidate, plaintext, context = {}) => { + const details = { + actor: actorKey, + snapshotId, + chunkIndex: context.chunkIndex ?? null, + chunkIdentifier: context.chunkIdentifier ?? null, + scheme: candidate.scheme, + source: candidate.source, + }; + try { + devLogger.info("[nostr] Attempting to encrypt watch history chunk.", details); + const encrypted = await candidate.handler(plaintext); + if (typeof encrypted !== "string" || !encrypted) { + throw new Error("empty-ciphertext"); + } + devLogger.info("[nostr] Encrypted watch history chunk.", details); + return { ciphertext: encrypted, scheme: candidate.scheme, candidate }; + } catch (error) { + devLogger.warn("[nostr] Failed to encrypt watch history chunk with candidate.", { + ...details, + error: error?.message || error, + }); + failedEncryptionCandidates.add(candidateIdentity(candidate)); + encryptionAttemptErrors.push({ + scheme: candidate.scheme, + source: candidate.source, + error, + context: { + snapshotId, + chunkIndex: context.chunkIndex ?? null, + chunkIdentifier: context.chunkIdentifier ?? null, + }, + }); + return null; + } + }; + + const encryptChunkPayload = async (plaintext, context = {}) => { + if ( + selectedEncryptor && + !failedEncryptionCandidates.has(candidateIdentity(selectedEncryptor)) + ) { + const reuseResult = await tryEncryptWithCandidate( + selectedEncryptor, + plaintext, + context, + ); + if (reuseResult) { + return reuseResult; + } + selectedEncryptor = null; + } + for (const candidate of encryptionCandidates) { + const identity = candidateIdentity(candidate); + if (failedEncryptionCandidates.has(identity)) { + continue; + } + const result = await tryEncryptWithCandidate(candidate, plaintext, context); + if (result) { + selectedEncryptor = candidate; + return result; + } + } + throw new Error("encryption-unavailable"); }; const signEvent = async (event) => { if (activeSigner) { @@ -1366,14 +1703,41 @@ class WatchHistoryManager { totalChunks: chunks.length, items: chunkItems, }); + const chunkIdentifier = `${snapshotId}:${index}`; let ciphertext = ""; + let encryptionScheme = ""; try { - ciphertext = await encryptChunk(plaintext); + const encryptionResult = await encryptChunkPayload(plaintext, { + chunkIndex: index, + chunkIdentifier, + }); + ciphertext = encryptionResult?.ciphertext || ""; + encryptionScheme = encryptionResult?.scheme || ""; } catch (error) { - userLogger.warn("[nostr] Failed to encrypt watch history chunk:", error); + userLogger.warn("[nostr] Failed to encrypt watch history chunk:", { + actor: actorKey, + snapshotId, + chunkIndex: index, + chunkIdentifier, + error: error?.message || error, + attempts: encryptionAttemptErrors.map((entry) => ({ + scheme: entry.scheme, + source: entry.source, + error: entry.error?.message || entry.error, + })), + }); + return { ok: false, error: "encryption-failed", retryable: false }; + } + if (!ciphertext) { + userLogger.warn("[nostr] Encryption produced an empty payload for watch history chunk.", { + actor: actorKey, + snapshotId, + chunkIndex: index, + chunkIdentifier, + scheme: encryptionScheme || null, + }); return { ok: false, error: "encryption-failed", retryable: false }; } - const chunkIdentifier = `${snapshotId}:${index}`; const event = buildWatchHistoryChunkEvent({ pubkey: actorPubkey, created_at: createdAtCursor, @@ -1383,6 +1747,7 @@ class WatchHistoryManager { totalChunks: chunks.length, pointerTags, content: ciphertext, + encryption: encryptionScheme, }); createdAtCursor += 1; let signedEvent; @@ -1735,7 +2100,7 @@ class WatchHistoryManager { normalizedLogged && normalizedLogged === actorKey && signer && - typeof signer.nip04Decrypt === "function"; + (typeof signer.nip04Decrypt === "function" || typeof signer.nip44Decrypt === "function"); const decryptSigner = canUseActiveSignerDecrypt ? signer : null; if (decryptSigner && this.deps.shouldRequestExtensionPermissions?.(decryptSigner)) { await this.deps.ensureExtensionPermissions?.(DEFAULT_NIP07_PERMISSION_METHODS); @@ -1976,79 +2341,95 @@ class WatchHistoryManager { } const decryptErrors = []; const collectedItems = []; - const ensureDecryptTools = async () => { - const tools = await ensureNostrTools(); - if (tools?.nip04 && typeof tools.nip04.decrypt === "function") { - devLogger.info("[nostr] Loaded nostr-tools nip04 helpers for watch history decryption."); - return tools; + const toolkitCache = { current: null }; + const decryptorCandidates = new Map(); + const registerDecryptor = (scheme, handler, source) => { + if (!scheme || typeof handler !== "function") { + return; } - userLogger.warn( - "[nostr] Unable to load nostr-tools nip04 helpers for watch history decryption.", - ); - return null; + const existing = decryptorCandidates.get(scheme) || []; + existing.push({ handler, source: source || "unknown" }); + decryptorCandidates.set(scheme, existing); }; - const decryptChunk = async (ciphertext, context = {}) => { - if (decryptSigner) { - devLogger.info("[nostr] Using active signer to decrypt watch history chunk.", { - actorKey, - chunkIdentifier: context.chunkIdentifier ?? null, - eventId: context.eventId ?? null, - }); - const plaintext = await decryptSigner.nip04Decrypt(actorKey, ciphertext); - devLogger.info("[nostr] Successfully decrypted watch history chunk via active signer.", { - actorKey, - chunkIdentifier: context.chunkIdentifier ?? null, - eventId: context.eventId ?? null, - }); - return plaintext; - } - const session = this.deps.getSessionActor?.(); - if (!session || session.pubkey !== actorKey) { - devLogger.info( - "[nostr] Session actor mismatch while decrypting watch history chunk. Ensuring session actor matches requested key.", + + if (decryptSigner) { + if (typeof decryptSigner.nip44Decrypt === "function") { + registerDecryptor( + "nip44_v2", + (payload) => decryptSigner.nip44Decrypt(actorKey, payload), + "active-signer", ); - const ensured = await this.deps.ensureSessionActor?.(); - if (normalizeActorKey(ensured) !== actorKey) { - throw new Error("session-actor-mismatch"); + registerDecryptor( + "nip44", + (payload) => decryptSigner.nip44Decrypt(actorKey, payload), + "active-signer", + ); + } + if (typeof decryptSigner.nip04Decrypt === "function") { + registerDecryptor( + "nip04", + (payload) => decryptSigner.nip04Decrypt(actorKey, payload), + "active-signer", + ); + } + } + + const ensureSessionDecryptors = async () => { + try { + let session = this.deps.getSessionActor?.(); + if (!session || session.pubkey !== actorKey || !session.privateKey) { + const ensured = await this.deps.ensureSessionActor?.(); + if (normalizeActorKey(ensured) !== actorKey) { + throw new Error("session-actor-mismatch"); + } + session = this.deps.getSessionActor?.(); } - } - const refreshedSession = this.deps.getSessionActor?.(); - if (!refreshedSession || refreshedSession.pubkey !== actorKey) { - throw new Error("session-actor-missing"); - } - const tools = await ensureDecryptTools(); - if (!tools?.nip04 || typeof tools.nip04.decrypt !== "function") { - userLogger.warn( - "[nostr] Unable to decrypt watch history chunk because nip04 helpers are unavailable.", - { - actorKey, - chunkIdentifier: context.chunkIdentifier ?? null, - eventId: context.eventId ?? null, - }, - ); - throw new Error("nip04-unavailable"); - } - devLogger.info( - "[nostr] Using session actor private key to decrypt watch history chunk.", - { + if (!session || session.pubkey !== actorKey) { + throw new Error("session-actor-missing"); + } + if (!session.privateKey) { + throw new Error("missing-session-key"); + } + const tools = await resolveNostrToolkit(this.deps, toolkitCache); + if (!tools) { + throw new Error("nostr-tools-missing"); + } + const suite = createNip44CipherSuite(tools, session.privateKey, actorKey); + if (suite?.v2?.decrypt) { + registerDecryptor( + "nip44_v2", + async (payload) => suite.v2.decrypt(payload), + "session-nip44_v2", + ); + registerDecryptor( + "nip44", + async (payload) => suite.v2.decrypt(payload), + "session-nip44_v2", + ); + } + if (suite?.legacy?.decrypt) { + registerDecryptor( + "nip44", + async (payload) => suite.legacy.decrypt(payload), + "session-nip44", + ); + } + if (tools?.nip04 && typeof tools.nip04.decrypt === "function") { + registerDecryptor( + "nip04", + async (payload) => tools.nip04.decrypt(session.privateKey, actorKey, payload), + "session-nip04", + ); + } + } catch (error) { + devLogger.warn("[nostr] Unable to register session decryptors for watch history.", { actorKey, - chunkIdentifier: context.chunkIdentifier ?? null, - eventId: context.eventId ?? null, - sessionActor: refreshedSession.pubkey ?? null, - }, - ); - const plaintext = await tools.nip04.decrypt( - refreshedSession.privateKey, - actorKey, - ciphertext, - ); - devLogger.info("[nostr] Successfully decrypted watch history chunk via session actor key.", { - actorKey, - chunkIdentifier: context.chunkIdentifier ?? null, - eventId: context.eventId ?? null, - }); - return plaintext; + error: error?.message || error, + }); + } }; + + await ensureSessionDecryptors(); const chunkCount = latestChunks.size || chunkIdentifiers.length || 0; const chunkKeys = chunkIdentifiers.length ? chunkIdentifiers @@ -2061,65 +2442,119 @@ class WatchHistoryManager { const fallbackPointers = extractPointerItemsFromEvent(event); const ciphertext = typeof event.content === "string" ? event.content : ""; const ciphertextPreview = ciphertext.slice(0, 32); - let payload; const chunkContext = { chunkIdentifier: identifier, eventId: event.id ?? null, }; - if (isNip04EncryptedWatchHistoryEvent(event, ciphertext)) { - devLogger.info("[nostr] Watch history chunk is marked as NIP-04 encrypted. Beginning decrypt flow.", { - actorKey, - ...chunkContext, - }); - try { - const plaintext = await decryptChunk(ciphertext, chunkContext); - devLogger.info("[nostr] Decrypted watch history chunk. Parsing plaintext payload.", { - actorKey, - ...chunkContext, - plaintextPreview: typeof plaintext === "string" ? plaintext.slice(0, 64) : null, - expectedPlaintextFormat: - "JSON string with { version, items, snapshot, chunkIndex, totalChunks }", - }); - payload = parseWatchHistoryContentWithFallback( - plaintext, - fallbackPointers, - { - version: 0, - items: fallbackPointers, - snapshot: snapshotId, - chunkIndex: 0, - totalChunks: chunkCount || 1, - }, - ); - } catch (error) { - decryptErrors.push(error); - userLogger.error( - "[nostr] Decrypt failed for watch history chunk. Falling back to pointer items.", - { + const availableSchemes = Array.from(decryptorCandidates.keys()); + const decryptOrder = determineWatchHistoryDecryptionOrder( + event, + ciphertext, + availableSchemes, + ); + let plaintext = null; + let usedScheme = ""; + let usedSource = ""; + const attemptFailures = []; + + if (decryptOrder.length) { + outer: for (const scheme of decryptOrder) { + const candidates = decryptorCandidates.get(scheme) || []; + for (const candidate of candidates) { + const details = { actorKey, ...chunkContext, - error: error?.message || error, - ciphertextPreview, - fallbackPointerCount: Array.isArray(fallbackPointers) - ? fallbackPointers.length - : 0, - expectedPlaintextFormat: - "JSON string with { version, items, snapshot, chunkIndex, totalChunks }", - }, - ); - payload = { - version: 0, - items: fallbackPointers, - }; + scheme, + source: candidate.source, + }; + try { + devLogger.info("[nostr] Attempting to decrypt watch history chunk.", details); + const result = await candidate.handler(ciphertext, chunkContext); + if (typeof result !== "string") { + throw new Error("invalid-plaintext"); + } + devLogger.info("[nostr] Successfully decrypted watch history chunk.", details); + plaintext = result; + usedScheme = scheme; + usedSource = candidate.source || ""; + break outer; + } catch (error) { + devLogger.warn("[nostr] Failed to decrypt watch history chunk with candidate.", { + ...details, + error: error?.message || error, + }); + attemptFailures.push({ scheme, source: candidate.source, error }); + } + } } - } else { - devLogger.info("[nostr] Watch history chunk is plaintext. Attempting to parse expected payload format.", { + } + + const encryptionHints = extractWatchHistoryEncryptionHints(event, ciphertext); + const isEncryptedChunk = + encryptionHints.length > 0 || + (!looksLikeJsonStructure(ciphertext) && Boolean(ciphertext.trim())); + + let payload; + if (plaintext !== null) { + devLogger.info("[nostr] Decrypted watch history chunk. Parsing plaintext payload.", { actorKey, ...chunkContext, - ciphertextPreview, + scheme: usedScheme || null, + source: usedSource || null, + plaintextPreview: typeof plaintext === "string" ? plaintext.slice(0, 64) : null, expectedPlaintextFormat: "JSON string with { version, items, snapshot, chunkIndex, totalChunks }", }); + payload = parseWatchHistoryContentWithFallback( + plaintext, + fallbackPointers, + { + version: 0, + items: fallbackPointers, + snapshot: snapshotId, + chunkIndex: 0, + totalChunks: chunkCount || 1, + }, + ); + } else if (isEncryptedChunk) { + decryptErrors.push({ + chunkIdentifier: identifier, + eventId: event.id ?? null, + attempts: attemptFailures, + }); + userLogger.error( + "[nostr] Decrypt failed for watch history chunk. Falling back to pointer items.", + { + actorKey, + ...chunkContext, + attempts: attemptFailures.map((entry) => ({ + scheme: entry.scheme, + source: entry.source, + error: entry.error?.message || entry.error, + })), + ciphertextPreview, + fallbackPointerCount: Array.isArray(fallbackPointers) + ? fallbackPointers.length + : 0, + expectedPlaintextFormat: + "JSON string with { version, items, snapshot, chunkIndex, totalChunks }", + }, + ); + payload = { + version: 0, + items: fallbackPointers, + }; + } else { + devLogger.info( + "[nostr] Watch history chunk is plaintext. Attempting to parse expected payload format.", + { + actorKey, + ...chunkContext, + ciphertextPreview, + expectedPlaintextFormat: + "JSON string with { version, items, snapshot, chunkIndex, totalChunks }", + }, + ); payload = parseWatchHistoryContentWithFallback( ciphertext, fallbackPointers, @@ -2132,6 +2567,7 @@ class WatchHistoryManager { }, ); } + if (Array.isArray(payload?.items)) { collectedItems.push(...payload.items); } diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js index 9f46e137..970fd11a 100644 --- a/js/nostrEventSchemas.js +++ b/js/nostrEventSchemas.js @@ -280,14 +280,14 @@ const BASE_SCHEMAS = { identifierTag: { name: "d", }, - encryptionTag: { name: "encrypted", value: "nip04" }, + encryptionTag: { name: "encrypted", values: ["nip44_v2", "nip44", "nip04"] }, snapshotTagName: "snapshot", chunkTagName: "chunk", headTag: { name: "head", value: "1" }, headTagIndex: 2, appendTags: DEFAULT_APPEND_TAGS, content: { - format: "nip04-json", + format: "encrypted-json", description: "Encrypted JSON payload containing chunked watch history entries.", }, @@ -1309,6 +1309,7 @@ export function buildWatchHistoryChunkEvent({ pointerTags = [], chunkAddresses = [], content, + encryption, }) { const schema = getNostrEventSchema(NOTE_TYPES.WATCH_HISTORY_CHUNK); const tags = []; @@ -1317,8 +1318,23 @@ export function buildWatchHistoryChunkEvent({ if (identifierName && identifierValue) { tags.push([identifierName, identifierValue]); } - if (schema?.encryptionTag?.name && schema?.encryptionTag?.value) { - tags.push([schema.encryptionTag.name, schema.encryptionTag.value]); + const encryptionTagName = schema?.encryptionTag?.name; + const normalizedOptions = Array.isArray(schema?.encryptionTag?.values) + ? schema.encryptionTag.values + .map((value) => (typeof value === "string" ? value.trim() : "")) + .filter(Boolean) + : []; + const normalizedRequested = typeof encryption === "string" ? encryption.trim() : ""; + let resolvedEncryptionTag = ""; + if (normalizedRequested) { + resolvedEncryptionTag = normalizedRequested; + } else if (typeof schema?.encryptionTag?.value === "string") { + resolvedEncryptionTag = schema.encryptionTag.value; + } else if (normalizedOptions.length) { + [resolvedEncryptionTag] = normalizedOptions; + } + if (encryptionTagName && resolvedEncryptionTag) { + tags.push([encryptionTagName, resolvedEncryptionTag]); } const snapshotTagName = schema?.snapshotTagName; if (snapshotTagName && snapshotId) { diff --git a/tests/watch-history.test.mjs b/tests/watch-history.test.mjs index 5f6559e5..ba4baaec 100644 --- a/tests/watch-history.test.mjs +++ b/tests/watch-history.test.mjs @@ -316,6 +316,32 @@ async function installSessionCrypto({ privateKey }) { const { createHash, randomBytes } = await import("node:crypto"); const deriveHex = (input) => createHash("sha256").update(String(input ?? ""), "utf8").digest("hex"); + const keyToHex = (value) => { + if (typeof value === "string") { + return value; + } + if (value instanceof Uint8Array || Array.isArray(value)) { + return Buffer.from(value).toString("hex"); + } + if (value && typeof value === "object" && typeof value.length === "number") { + return Buffer.from(value).toString("hex"); + } + return deriveHex(value); + }; + const encodePayload = (prefix, conversationKey, plaintext) => { + const keySegment = keyToHex(conversationKey); + const payload = Buffer.from(plaintext, "utf8").toString("base64"); + return `${prefix}:${keySegment}:${payload}`; + }; + const decodePayload = (prefix, conversationKey, ciphertext) => { + const keySegment = keyToHex(conversationKey); + const expected = `${prefix}:${keySegment}:`; + if (!ciphertext.startsWith(expected)) { + throw new Error("invalid-session-ciphertext"); + } + const encoded = ciphertext.slice(expected.length); + return Buffer.from(encoded, "base64").toString("utf8"); + }; const previousEnsure = nostrClient.watchHistory?.deps?.ensureNostrTools || null; const previousGetCached = @@ -350,6 +376,40 @@ async function installSessionCrypto({ privateKey }) { return Buffer.from(encoded, "base64").toString("utf8"); }, }, + nip44: { + ...(original.nip44 || {}), + encrypt: (plaintext, conversationKey) => { + encryptCalls += 1; + return encodePayload("session44-legacy", conversationKey, plaintext); + }, + decrypt: (ciphertext, conversationKey) => { + decryptCalls += 1; + return decodePayload("session44-legacy", conversationKey, ciphertext); + }, + getConversationKey: (privBytes, target) => + `${keyToHex(privBytes)}:${target}:legacy`, + utils: { + ...(original.nip44?.utils || {}), + getConversationKey: (privBytes, target) => + `${keyToHex(privBytes)}:${target}:legacy-utils`, + }, + v2: { + ...(original.nip44?.v2 || {}), + encrypt: (plaintext, conversationKey) => { + encryptCalls += 1; + return encodePayload("session44", conversationKey, plaintext); + }, + decrypt: (ciphertext, conversationKey) => { + decryptCalls += 1; + return decodePayload("session44", conversationKey, ciphertext); + }, + utils: { + ...(original.nip44?.v2?.utils || {}), + getConversationKey: (privBytes, target) => + `${keyToHex(privBytes)}:${target}:v2`, + }, + }, + }, }; rememberNostrTools(window.NostrTools); globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ = window.NostrTools; @@ -376,10 +436,11 @@ async function installSessionCrypto({ privateKey }) { }, getEncryptCalls: () => encryptCalls, getDecryptCalls: () => decryptCalls, + getPrivateKey: () => privateKey, }; } -function installExtensionCrypto({ actor }) { +function installExtensionCrypto({ actor, supportsNip44 = true }) { const originalNostr = window.nostr; const originalTools = window.NostrTools || {}; let extensionEncrypts = 0; @@ -387,6 +448,18 @@ function installExtensionCrypto({ actor }) { let fallbackEncrypts = 0; const enableCalls = []; const decryptCalls = []; + const encodePayload = (prefix, target, plaintext) => { + const payload = Buffer.from(plaintext, "utf8").toString("base64"); + return `${prefix}:${target}:${payload}`; + }; + const decodePayload = (prefix, target, ciphertext) => { + const expected = `${prefix}:${target}:`; + if (!ciphertext.startsWith(expected)) { + throw new Error("invalid-extension-ciphertext"); + } + const encoded = ciphertext.slice(expected.length); + return Buffer.from(encoded, "base64").toString("utf8"); + }; window.nostr = { enable: async (options) => { enableCalls.push(options || null); @@ -400,21 +473,28 @@ function installExtensionCrypto({ actor }) { nip04: { encrypt: async (target, plaintext) => { extensionEncrypts += 1; - const payload = Buffer.from(plaintext, "utf8").toString("base64"); - return `extension:${target}:${payload}`; + return encodePayload("extension", target, plaintext); }, decrypt: async (target, ciphertext) => { extensionDecrypts += 1; - decryptCalls.push({ target, ciphertext }); - const prefix = `extension:${target}:`; - if (!ciphertext.startsWith(prefix)) { - throw new Error("invalid-extension-ciphertext"); - } - const encoded = ciphertext.slice(prefix.length); - return Buffer.from(encoded, "base64").toString("utf8"); + decryptCalls.push({ scheme: "nip04", target, ciphertext }); + return decodePayload("extension", target, ciphertext); }, }, }; + if (supportsNip44) { + window.nostr.nip44 = { + encrypt: async (target, plaintext) => { + extensionEncrypts += 1; + return encodePayload("extension44", target, plaintext); + }, + decrypt: async (target, ciphertext) => { + extensionDecrypts += 1; + decryptCalls.push({ scheme: "nip44", target, ciphertext }); + return decodePayload("extension44", target, ciphertext); + }, + }; + } window.NostrTools = { ...originalTools, nip04: { @@ -423,27 +503,24 @@ function installExtensionCrypto({ actor }) { fallbackEncrypts += 1; throw new Error("fallback-encrypt-used"); }, - decrypt: async (secret, pub, ciphertext) => { - const prefix = `session:${secret}:${pub}:`; - if (!ciphertext.startsWith(prefix)) { - throw new Error("invalid-session-ciphertext"); - } - const encoded = ciphertext.slice(prefix.length); - return Buffer.from(encoded, "base64").toString("utf8"); - }, }, }; rememberNostrTools(window.NostrTools); globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ = window.NostrTools; globalThis.nostrToolsReady = Promise.resolve(window.NostrTools); const previousSigner = getActiveSigner(); - setActiveSigner({ + const signer = { type: "extension", pubkey: actor, signEvent: window.nostr.signEvent, nip04Encrypt: window.nostr.nip04.encrypt, nip04Decrypt: window.nostr.nip04.decrypt, - }); + }; + if (supportsNip44) { + signer.nip44Encrypt = window.nostr.nip44.encrypt; + signer.nip44Decrypt = window.nostr.nip44.decrypt; + } + setActiveSigner(signer); return { restore() { if (previousSigner) { @@ -611,49 +688,19 @@ async function testFetchWatchHistoryExtensionDecryptsHexAndNpub() { const fallbackTag = ["e", "fallback-pointer"]; const fallbackItem = { type: "e", value: "fallback-pointer", watchedAt: 1_700_800_000 }; const chunkItem = { type: "e", value: "chunk-pointer", watchedAt: 1_700_800_100 }; + const chunkLegacyItem = { + type: "e", + value: "legacy-pointer", + watchedAt: 1_700_800_120, + }; const chunkPayload = JSON.stringify({ - version: 1, + version: 2, items: [chunkItem], snapshot: snapshotId, chunkIndex: 0, - totalChunks: 1, + totalChunks: 2, }); - const chunkCiphertext = `extension:${actorHex}:${Buffer.from(chunkPayload, "utf8").toString("base64")}`; - - const pointerEvent = { - id: "pointer-extension", - kind: WATCH_HISTORY_KIND, - pubkey: actorHex, - created_at: 1_700_800_000, - content: JSON.stringify({ - version: 1, - items: [fallbackItem], - snapshot: snapshotId, - chunkIndex: 0, - totalChunks: 1, - }), - tags: [ - ["d", WATCH_HISTORY_LIST_IDENTIFIER], - ["snapshot", snapshotId], - ["encrypted", "nip04"], - ["a", `${WATCH_HISTORY_KIND}:${actorHex}:${chunkIdentifier}`], - fallbackTag, - ], - }; - - const chunkEvent = { - id: "chunk-extension", - kind: WATCH_HISTORY_KIND, - pubkey: actorHex, - created_at: 1_700_800_060, - content: chunkCiphertext, - tags: [ - ["d", chunkIdentifier], - ["snapshot", snapshotId], - ["encrypted", "nip04"], - ], - }; if (!window.NostrTools || typeof window.NostrTools !== "object") { window.NostrTools = {}; @@ -686,19 +733,84 @@ async function testFetchWatchHistoryExtensionDecryptsHexAndNpub() { nostrClient.watchHistoryCache = new Map(); nostrClient.watchHistoryStorage = { version: 2, actors: {} }; - const publishEvents = async () => { + const runVariant = async (label, actorInput, pubkeyInput) => { + const sessionCrypto = await installSessionCrypto({ privateKey: `session-${label}` }); + + const sessionTools = window.NostrTools; + const legacyPayload = JSON.stringify({ + version: 2, + items: [chunkLegacyItem], + snapshot: snapshotId, + chunkIndex: 1, + totalChunks: 2, + }); + const legacyCiphertext = await sessionTools.nip04.encrypt( + sessionCrypto.getPrivateKey(), + actorHex, + legacyPayload, + ); + + const extensionCrypto = installExtensionCrypto({ actor: actorHex }); + poolHarness.reset(); poolHarness.setResolver(() => ({ ok: true })); + + const nip44Ciphertext = await window.nostr.nip44.encrypt(actorHex, chunkPayload); + + const pointerEvent = { + id: `pointer-${label}`, + kind: WATCH_HISTORY_KIND, + pubkey: actorHex, + created_at: 1_700_800_000, + content: JSON.stringify({ + version: 2, + items: [fallbackItem], + snapshot: snapshotId, + chunkIndex: 0, + totalChunks: 2, + }), + tags: [ + ["d", WATCH_HISTORY_LIST_IDENTIFIER], + ["snapshot", snapshotId], + ["encrypted", "nip44_v2"], + ["a", `${WATCH_HISTORY_KIND}:${actorHex}:${chunkIdentifier}`], + ["a", `${WATCH_HISTORY_KIND}:${actorHex}:${chunkIdentifier}-legacy`], + fallbackTag, + ], + }; + + const chunkEvent = { + id: `chunk-extension-${label}`, + kind: WATCH_HISTORY_KIND, + pubkey: actorHex, + created_at: 1_700_800_060, + content: nip44Ciphertext, + tags: [ + ["d", chunkIdentifier], + ["snapshot", snapshotId], + ["chunk", "0", "2"], + ["encrypted", "nip44_v2"], + ], + }; + + const chunkLegacyEvent = { + id: `chunk-legacy-${label}`, + kind: WATCH_HISTORY_KIND, + pubkey: actorHex, + created_at: 1_700_800_120, + content: legacyCiphertext, + tags: [ + ["d", `${chunkIdentifier}-legacy`], + ["snapshot", snapshotId], + ["chunk", "1", "2"], + ["encrypted", "nip04"], + ], + }; + poolHarness.publish(["wss://relay.test"], pointerEvent); poolHarness.publish(["wss://relay.test"], chunkEvent); + poolHarness.publish(["wss://relay.test"], chunkLegacyEvent); await new Promise((resolve) => setTimeout(resolve, 0)); - }; - - const runVariant = async (label, actorInput, pubkeyInput) => { - await publishEvents(); - - const sessionCrypto = await installSessionCrypto({ privateKey: `session-${label}` }); - const extensionCrypto = installExtensionCrypto({ actor: actorHex }); const previousEnsure = nostrClient.ensureSessionActor; const previousSession = nostrClient.sessionActor; @@ -707,10 +819,11 @@ async function testFetchWatchHistoryExtensionDecryptsHexAndNpub() { try { nostrClient.pubkey = pubkeyInput; - nostrClient.sessionActor = null; - nostrClient.ensureSessionActor = async () => { - throw new Error(`session fallback should not run for ${label}`); + nostrClient.sessionActor = { + pubkey: actorHex, + privateKey: sessionCrypto.getPrivateKey(), }; + nostrClient.ensureSessionActor = async () => actorHex; nostrClient.watchHistoryCache.clear(); nostrClient.watchHistoryStorage = { version: 2, actors: {} }; @@ -720,31 +833,46 @@ async function testFetchWatchHistoryExtensionDecryptsHexAndNpub() { assert.equal( extensionCrypto.getExtensionDecrypts(), - 1, - `${label} actor should use extension decrypt`, + 2, + `${label} actor should attempt extension decrypt for each encrypted chunk`, ); assert.equal( sessionCrypto.getDecryptCalls(), - 0, - `${label} actor should not trigger session decrypt fallback`, + 1, + `${label} actor should invoke session decrypt for nip04 fallback chunks`, ); const decryptCalls = extensionCrypto.getDecryptCalls(); assert.equal( decryptCalls.length, - 1, - `${label} actor should invoke extension decrypt exactly once`, + 2, + `${label} actor should attempt extension decrypt for both chunk schemes`, ); assert.equal( decryptCalls[0]?.target, actorHex, `${label} actor should normalize npub inputs to hex for decrypt`, ); - - assert.equal(result.items.length, 1, `${label} actor should return chunk item`); assert.equal( - result.items[0]?.value, - chunkItem.value, - `${label} actor should surface decrypted pointer value`, + decryptCalls[0]?.scheme, + "nip44", + `${label} actor should use nip44 for the first chunk`, + ); + assert.equal( + decryptCalls[1]?.scheme, + "nip04", + `${label} actor should attempt nip04 via the extension before falling back`, + ); + + assert.equal( + result.items.length, + 2, + `${label} actor should merge decrypted items from both chunks`, + ); + const values = result.items.map((item) => item?.value); + assert(values.includes(chunkItem.value), `${label} actor should include nip44 chunk item`); + assert( + values.includes(chunkLegacyItem.value), + `${label} actor should include nip04 chunk item`, ); } finally { extensionCrypto.restore(); @@ -973,14 +1101,23 @@ async function testPublishSnapshotUsesExtensionCrypto() { ); assert(extension.getExtensionEncrypts() > 0, "extension encrypt should be invoked"); - const decrypt = window.nostr?.nip04?.decrypt; + const chunkEvent = result.chunkEvents[0]; + const schemeTag = Array.isArray(chunkEvent?.tags) + ? chunkEvent.tags.find((tag) => Array.isArray(tag) && tag[0] === "encrypted") + : null; + const encryptionScheme = typeof schemeTag?.[1] === "string" ? schemeTag[1] : ""; + assert.equal(encryptionScheme, "nip44_v2", "chunk should advertise nip44_v2 encryption"); + const decrypt = + encryptionScheme === "nip44_v2" || encryptionScheme === "nip44" + ? window.nostr?.nip44?.decrypt + : window.nostr?.nip04?.decrypt; assert.equal( typeof decrypt, "function", "extension decrypt helper should be available", ); - const decrypted = await decrypt(actor, result.chunkEvents[0].content); + const decrypted = await decrypt(actor, chunkEvent.content); const payload = JSON.parse(decrypted); assert.equal(payload.snapshot, "extension"); assert.equal( @@ -988,6 +1125,12 @@ async function testPublishSnapshotUsesExtensionCrypto() { result.items.length, "decrypted payload should match canonical item count", ); + const decryptCalls = extension.getDecryptCalls(); + assert.equal( + decryptCalls[0]?.scheme, + "nip44", + "extension decrypt should prefer nip44 before falling back", + ); assert.equal( extension.getEnableCalls().length, 1, @@ -1002,6 +1145,67 @@ async function testPublishSnapshotUsesExtensionCrypto() { } } +async function testPublishSnapshotFallsBackToNip04WhenNip44Unavailable() { + poolHarness.reset(); + poolHarness.setResolver(() => ({ ok: true })); + + const actor = "ext-fallback-pubkey"; + const sessionRestore = await installSessionCrypto({ privateKey: "fallback-priv" }); + const extension = installExtensionCrypto({ actor, supportsNip44: false }); + const originalEnsure = nostrClient.ensureSessionActor; + const originalSession = nostrClient.sessionActor; + const originalPub = nostrClient.pubkey; + + try { + nostrClient.pubkey = actor; + nostrClient.sessionActor = null; + nostrClient.ensureSessionActor = async () => actor; + + const result = await nostrClient.publishWatchHistorySnapshot( + [ + { type: "e", value: "fallback-pointer-1", watchedAt: 90 }, + { type: "e", value: "fallback-pointer-2", watchedAt: 60 }, + ], + { actorPubkey: actor, snapshotId: "extension-nip04" }, + ); + + assert.ok(result.ok, "snapshot should succeed when extension lacks nip44"); + const chunkEvent = result.chunkEvents[0]; + const schemeTag = Array.isArray(chunkEvent?.tags) + ? chunkEvent.tags.find((tag) => Array.isArray(tag) && tag[0] === "encrypted") + : null; + assert.equal( + schemeTag?.[1], + "nip04", + "chunk should advertise nip04 when nip44 is unavailable", + ); + + const decrypt = window.nostr?.nip04?.decrypt; + assert.equal(typeof decrypt, "function", "nip04 decrypt helper should be available"); + const decrypted = await decrypt(actor, chunkEvent.content); + const payload = JSON.parse(decrypted); + assert.equal(payload.snapshot, "extension-nip04"); + const decryptCalls = extension.getDecryptCalls(); + assert.equal(decryptCalls.length, 1, "nip04 fallback should invoke a single decrypt attempt"); + assert.equal( + decryptCalls[0]?.scheme, + "nip04", + "extension decrypt should use nip04 when nip44 is unavailable", + ); + assert.equal( + extension.getExtensionDecrypts(), + 1, + "extension nip04 decrypt should be invoked exactly once", + ); + } finally { + extension.restore(); + sessionRestore.restore(); + nostrClient.ensureSessionActor = originalEnsure; + nostrClient.sessionActor = originalSession; + nostrClient.pubkey = originalPub; + } +} + async function testEnsureExtensionPermissionCaching() { const actor = "permission-actor"; const extension = installExtensionCrypto({ actor }); @@ -2453,6 +2657,7 @@ async function testWatchHistoryAppLoginFallback() { await testPublishSnapshotCanonicalizationAndChunking(); await testPublishSnapshotUsesExtensionCrypto(); +await testPublishSnapshotFallsBackToNip04WhenNip44Unavailable(); await testEnsureExtensionPermissionCaching(); await testFetchWatchHistoryExtensionDecryptsHexAndNpub(); await testPublishSnapshotFailureRetry();