From 5d7df0eb61e3337e151f13d81bfbf7930bbc7d63 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 9 Oct 2025 15:37:52 -0400 Subject: [PATCH] Add manual npub decode fallback and expand tests --- js/nostr.js | 153 ++++++++++++++++++++++++++++++++--- tests/watch-history.test.mjs | 44 ++++++++++ 2 files changed, 186 insertions(+), 11 deletions(-) diff --git a/js/nostr.js b/js/nostr.js index 1ec94372..b40d1bfe 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -2590,6 +2590,126 @@ function cloneEventForCache(event) { return cloned; } +const BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; +const BECH32_CHARSET_MAP = (() => { + const map = new Map(); + for (let index = 0; index < BECH32_CHARSET.length; index += 1) { + map.set(BECH32_CHARSET[index], index); + } + return map; +})(); + +const BECH32_GENERATORS = [ + 0x3b6a57b2, + 0x26508e6d, + 0x1ea119fa, + 0x3d4233dd, + 0x2a1462b3, +]; + +function bech32Polymod(values) { + let chk = 1; + for (const value of values) { + const top = chk >>> 25; + chk = ((chk & 0x1ffffff) << 5) ^ value; + for (let bit = 0; bit < BECH32_GENERATORS.length; bit += 1) { + if ((top >>> bit) & 1) { + chk ^= BECH32_GENERATORS[bit]; + } + } + } + return chk; +} + +function bech32HrpExpand(hrp) { + const expansion = []; + for (let i = 0; i < hrp.length; i += 1) { + expansion.push(hrp.charCodeAt(i) >>> 5); + } + expansion.push(0); + for (let i = 0; i < hrp.length; i += 1) { + expansion.push(hrp.charCodeAt(i) & 31); + } + return expansion; +} + +function bech32VerifyChecksum(hrp, values) { + return bech32Polymod([...bech32HrpExpand(hrp), ...values]) === 1; +} + +function convertBits(data, fromBits, toBits) { + let acc = 0; + let bits = 0; + const maxValue = (1 << toBits) - 1; + const result = []; + + for (const value of data) { + if (value < 0 || value >>> fromBits !== 0) { + return null; + } + acc = (acc << fromBits) | value; + bits += fromBits; + while (bits >= toBits) { + bits -= toBits; + result.push((acc >>> bits) & maxValue); + } + } + + if (bits > 0) { + if ((acc << (toBits - bits)) & maxValue) { + return null; + } + } + + return result; +} + +function decodeBech32Npub(value) { + if (typeof value !== "string") { + return ""; + } + + const hasMixedCase = value !== value.toLowerCase() && value !== value.toUpperCase(); + if (hasMixedCase) { + return ""; + } + + const normalized = value.toLowerCase(); + const separatorIndex = normalized.lastIndexOf("1"); + if (separatorIndex <= 0 || separatorIndex + 7 > normalized.length) { + return ""; + } + + const hrp = normalized.slice(0, separatorIndex); + if (hrp !== "npub") { + return ""; + } + + const dataPart = normalized.slice(separatorIndex + 1); + const values = []; + for (let i = 0; i < dataPart.length; i += 1) { + const mapped = BECH32_CHARSET_MAP.get(dataPart[i]); + if (typeof mapped !== "number") { + return ""; + } + values.push(mapped); + } + + if (values.length < 7 || !bech32VerifyChecksum(hrp, values)) { + return ""; + } + + const words = values.slice(0, -6); + const bytes = convertBits(words, 5, 8); + if (!bytes || bytes.length !== 32) { + return ""; + } + + return Array.from(bytes) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} + function decodeNpubToHex(npub) { if (typeof npub !== "string") { return ""; @@ -2621,20 +2741,31 @@ function decodeNpubToHex(npub) { tools = fallbackTools; } } - if (!tools?.nip19 || typeof tools.nip19.decode !== "function") { - return ""; + + let decodeError = null; + if (tools?.nip19 && typeof tools.nip19.decode === "function") { + try { + const decoded = tools.nip19.decode(trimmed); + if (decoded?.type === "npub" && typeof decoded.data === "string") { + return decoded.data; + } + } catch (error) { + decodeError = error; + } } - try { - const decoded = tools.nip19.decode(trimmed); - if (decoded?.type === "npub" && typeof decoded.data === "string") { - return decoded.data; - } - } catch (error) { - if (isDevMode && warnableNpub) { - console.warn(`[nostr] Failed to decode npub: ${trimmed}`, error); - } + const manualDecoded = decodeBech32Npub(trimmed); + if (manualDecoded) { + return manualDecoded; } + + if (isDevMode && warnableNpub) { + console.warn( + `[nostr] Failed to decode npub: ${trimmed}`, + decodeError || new Error("invalid-npub"), + ); + } + return ""; } diff --git a/tests/watch-history.test.mjs b/tests/watch-history.test.mjs index f5d3705f..85116589 100644 --- a/tests/watch-history.test.mjs +++ b/tests/watch-history.test.mjs @@ -425,6 +425,49 @@ async function testNormalizeActorKeyShortCircuit() { } } +async function testNormalizeActorKeyManualFallback() { + console.log("Running normalizeActorKey manual fallback test..."); + + const actorHex = + "a4a6b5849bc917b3befd5c81865ee0b88773690609c207ba6588ef3e1e05b95b"; + const actorNpub = + "npub15jnttpymeytm80hatjqcvhhqhzrhx6gxp8pq0wn93rhnu8s9h9dsha32lx"; + + if (!window.NostrTools || typeof window.NostrTools !== "object") { + window.NostrTools = {}; + } + + const previousNip19 = window.NostrTools.nip19; + const originalWarn = console.warn; + const warnings = []; + + delete window.NostrTools.nip19; + console.warn = (...args) => { + warnings.push(args); + }; + + try { + const normalized = normalizeActorKey(actorNpub); + assert.equal( + normalized, + actorHex, + "manual fallback should decode npub values to hex", + ); + assert.equal( + warnings.length, + 0, + "manual fallback decode should not emit warnings", + ); + } finally { + console.warn = originalWarn; + if (previousNip19 !== undefined) { + window.NostrTools.nip19 = previousNip19; + } else { + delete window.NostrTools.nip19; + } + } +} + async function testFetchWatchHistoryExtensionDecryptsHexAndNpub() { const actorHex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; const actorNpub = @@ -1658,6 +1701,7 @@ await testWatchHistoryLocalFallbackWhenDisabled(); await testWatchHistorySyncEnabledForLoggedInUsers(); await testWatchHistoryAppLoginFallback(); await testNormalizeActorKeyShortCircuit(); +await testNormalizeActorKeyManualFallback(); console.log("watch-history.test.mjs completed successfully");