diff --git a/js/nostr.js b/js/nostr.js index 6dd444b3..fa98de07 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -54,137 +54,29 @@ import { resolveSimplePoolConstructor, } from "./nostr/toolkit.js"; import { devLogger, userLogger } from "./utils/logger.js"; +import { + DEFAULT_NIP07_ENCRYPTION_METHODS, + DEFAULT_NIP07_PERMISSION_METHODS, + clearStoredNip07Permissions, + normalizePermissionMethod, + readStoredNip07Permissions, + requestEnablePermissions, + runNip07WithRetry, + writeStoredNip07Permissions, +} from "./nostr/nip07Permissions.js"; +import { + clearStoredSessionActor as clearStoredSessionActorEntry, + decryptSessionPrivateKey, + encryptSessionPrivateKey, + persistSessionActor as persistSessionActorEntry, + readStoredSessionActorEntry, +} from "./nostr/sessionActor.js"; let activeSigner = null; const EVENTS_CACHE_STORAGE_KEY = "bitvid:eventsCache:v1"; const LEGACY_EVENTS_STORAGE_KEY = "bitvidEvents"; const EVENTS_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes -const NIP07_LOGIN_TIMEOUT_MS = 60_000; // 60 seconds -const NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE = - "Timed out waiting for the NIP-07 extension. Confirm the extension prompt in your browser toolbar and try again."; -const NIP07_PERMISSIONS_STORAGE_KEY = "bitvid:nip07:permissions"; - -// Give the NIP-07 extension enough time to surface its approval prompt and let -// users unlock/authorize it. Seven seconds proved too aggressive once vendors -// started requiring an unlock step, so we extend the window substantially while -// still allowing manual overrides via __BITVID_NIP07_ENABLE_VARIANT_TIMEOUT_MS__. -const DEFAULT_ENABLE_VARIANT_TIMEOUT_MS = 45_000; - -function getEnableVariantTimeoutMs() { - const overrideValue = - typeof globalThis !== "undefined" && - globalThis !== null && - Number.isFinite(globalThis.__BITVID_NIP07_ENABLE_VARIANT_TIMEOUT_MS__) - ? Math.floor(globalThis.__BITVID_NIP07_ENABLE_VARIANT_TIMEOUT_MS__) - : null; - - if (overrideValue !== null && overrideValue > 0) { - return Math.max(50, overrideValue); - } - - return DEFAULT_ENABLE_VARIANT_TIMEOUT_MS; -} - -function normalizePermissionMethod(method) { - return typeof method === "string" && method.trim() ? method.trim() : ""; -} - -function getNip07PermissionStorage() { - const scope = - typeof globalThis !== "undefined" && globalThis ? globalThis : undefined; - const browserWindow = - typeof window !== "undefined" && window ? window : undefined; - - if (browserWindow?.localStorage) { - return browserWindow.localStorage; - } - - if (scope?.localStorage) { - return scope.localStorage; - } - - return null; -} - -function readStoredNip07Permissions() { - const storage = getNip07PermissionStorage(); - if (!storage) { - return new Set(); - } - - let rawValue = null; - try { - rawValue = storage.getItem(NIP07_PERMISSIONS_STORAGE_KEY); - } catch (error) { - return new Set(); - } - - if (!rawValue) { - return new Set(); - } - - try { - const parsed = JSON.parse(rawValue); - const storedMethods = Array.isArray(parsed) - ? parsed - : Array.isArray(parsed?.grantedMethods) - ? parsed.grantedMethods - : Array.isArray(parsed?.methods) - ? parsed.methods - : []; - return new Set( - storedMethods - .map((method) => normalizePermissionMethod(method)) - .filter(Boolean), - ); - } catch (error) { - clearStoredNip07Permissions(); - return new Set(); - } -} - -function writeStoredNip07Permissions(methods) { - const storage = getNip07PermissionStorage(); - if (!storage) { - return; - } - - const normalized = Array.from( - new Set( - Array.from(methods || []) - .map((method) => normalizePermissionMethod(method)) - .filter(Boolean), - ), - ); - - try { - if (!normalized.length) { - storage.removeItem(NIP07_PERMISSIONS_STORAGE_KEY); - return; - } - - storage.setItem( - NIP07_PERMISSIONS_STORAGE_KEY, - JSON.stringify({ grantedMethods: normalized }), - ); - } catch (error) { - // ignore persistence failures - } -} - -function clearStoredNip07Permissions() { - const storage = getNip07PermissionStorage(); - if (!storage) { - return; - } - - try { - storage.removeItem(NIP07_PERMISSIONS_STORAGE_KEY); - } catch (error) { - // ignore cleanup issues - } -} const NIP46_RPC_KIND = 24_133; const NIP46_SESSION_STORAGE_KEY = "bitvid:nip46:session:v1"; const NIP46_PUBLISH_TIMEOUT_MS = 8_000; @@ -362,13 +254,6 @@ function clearStoredNip46Session() { // ignore cleanup issues } } -const SESSION_ACTOR_STORAGE_KEY = "bitvid:sessionActor:v1"; -const SESSION_ACTOR_ENCRYPTION_VERSION = 1; -const SESSION_ACTOR_KDF_ITERATIONS = 250_000; -const SESSION_ACTOR_KDF_HASH = "SHA-256"; -const SESSION_ACTOR_ENCRYPTION_ALGORITHM = "AES-GCM"; -const SESSION_ACTOR_SALT_BYTES = 16; -const SESSION_ACTOR_IV_BYTES = 12; const HEX64_REGEX = /^[0-9a-f]{64}$/i; const VIEW_EVENT_GUARD_PREFIX = "bitvid:viewed"; const REBROADCAST_GUARD_PREFIX = "bitvid:rebroadcast:v1"; @@ -380,25 +265,6 @@ const WATCH_HISTORY_REPUBLISH_MAX_DELAY_MS = 5 * 60 * 1000; const WATCH_HISTORY_REPUBLISH_MAX_ATTEMPTS = 8; const WATCH_HISTORY_REPUBLISH_JITTER = 0.25; -export const DEFAULT_NIP07_ENCRYPTION_METHODS = Object.freeze([ - // Encryption helpers — request both legacy NIP-04 and modern NIP-44 upfront - "nip04.encrypt", - "nip04.decrypt", - "nip44.encrypt", - "nip44.decrypt", - "nip44.v2.encrypt", - "nip44.v2.decrypt", -]); - -export const DEFAULT_NIP07_PERMISSION_METHODS = Object.freeze([ - // Core auth + relay metadata - "get_public_key", - "sign_event", - "read_relays", - "write_relays", - ...DEFAULT_NIP07_ENCRYPTION_METHODS, -]); - const viewEventPublishMemory = new Map(); const rebroadcastAttemptMemory = new Map(); @@ -446,101 +312,6 @@ function logErrorOnce(message, eventContent = null) { } } -function withNip07Timeout( - operation, - { - timeoutMs = NIP07_LOGIN_TIMEOUT_MS, - message = NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE, - } = {}, -) { - const numericTimeout = Number(timeoutMs); - const effectiveTimeout = - Number.isFinite(numericTimeout) && numericTimeout > 0 - ? numericTimeout - : NIP07_LOGIN_TIMEOUT_MS; - - let timeoutId; - const timeoutPromise = new Promise((_, reject) => { - timeoutId = setTimeout(() => { - reject(new Error(message)); - }, effectiveTimeout); - }); - - let operationResult; - try { - operationResult = operation(); - } catch (err) { - if (timeoutId) { - clearTimeout(timeoutId); - } - throw err; - } - - const operationPromise = Promise.resolve(operationResult); - - return Promise.race([operationPromise, timeoutPromise]).finally(() => { - if (timeoutId) { - clearTimeout(timeoutId); - } - }); -} - -async function runNip07WithRetry( - operation, - { - label = "NIP-07 operation", - timeoutMs = NIP07_LOGIN_TIMEOUT_MS, - retryMultiplier = 2, - } = {}, -) { - let hasStarted = false; - let cachedPromise = null; - - const getOrStartOperation = () => { - if (!hasStarted) { - hasStarted = true; - try { - cachedPromise = Promise.resolve(operation()); - } catch (error) { - hasStarted = false; - cachedPromise = null; - throw error; - } - } - - return cachedPromise; - }; - - try { - return await withNip07Timeout(getOrStartOperation, { - timeoutMs, - message: NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE, - }); - } catch (error) { - const isTimeoutError = - error instanceof Error && - error.message === NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE; - - if (!isTimeoutError || retryMultiplier <= 1) { - throw error; - } - - const extendedTimeout = Math.max( - timeoutMs, - Math.round(timeoutMs * retryMultiplier), - ); - - devLogger.warn( - `[nostr] ${label} taking longer than ${timeoutMs}ms. Waiting up to ${extendedTimeout}ms for extension response.`, - ); - - return withNip07Timeout(getOrStartOperation, { - timeoutMs: extendedTimeout, - message: NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE, - }); - } -} - export const __testExports = { runNip07WithRetry, DEFAULT_NIP07_ENCRYPTION_METHODS, @@ -2695,314 +2466,6 @@ function bytesToHex(bytes) { .join(""); } -function arrayBufferToBase64(buffer) { - if (!buffer) { - return ""; - } - - let view; - if (buffer instanceof ArrayBuffer) { - view = new Uint8Array(buffer); - } else if (ArrayBuffer.isView(buffer)) { - view = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength); - } else { - return ""; - } - - if (typeof globalThis?.btoa === "function") { - let binary = ""; - for (let index = 0; index < view.length; index += 1) { - binary += String.fromCharCode(view[index]); - } - return globalThis.btoa(binary); - } - - if (typeof Buffer !== "undefined") { - return Buffer.from(view).toString("base64"); - } - - return ""; -} - -function base64ToUint8Array(base64) { - if (typeof base64 !== "string" || !base64.trim()) { - return null; - } - - let binary; - try { - if (typeof globalThis?.atob === "function") { - binary = globalThis.atob(base64); - } else if (typeof Buffer !== "undefined") { - binary = Buffer.from(base64, "base64").toString("binary"); - } else { - return null; - } - } catch (error) { - return null; - } - - const bytes = new Uint8Array(binary.length); - for (let index = 0; index < binary.length; index += 1) { - bytes[index] = binary.charCodeAt(index); - } - return bytes; -} - -function generateRandomBytes(length) { - const size = Number.isFinite(length) ? Math.max(0, Math.floor(length)) : 0; - if (size <= 0) { - return new Uint8Array(0); - } - - if (globalThis?.crypto?.getRandomValues) { - const array = new Uint8Array(size); - globalThis.crypto.getRandomValues(array); - return array; - } - - throw new Error("secure-random-unavailable"); -} - -function isSubtleCryptoAvailable() { - return !!( - globalThis?.crypto?.subtle && - typeof globalThis.crypto.subtle.importKey === "function" - ); -} - -async function deriveSessionEncryptionKey(passphrase, saltBytes, iterations, hash) { - if (!isSubtleCryptoAvailable()) { - throw new Error("webcrypto-unavailable"); - } - - if (!(saltBytes instanceof Uint8Array)) { - throw new Error("invalid-salt"); - } - - const encoder = typeof TextEncoder === "function" ? new TextEncoder() : null; - if (!encoder) { - throw new Error("text-encoder-unavailable"); - } - - const passphraseBytes = encoder.encode(passphrase); - - const baseKey = await globalThis.crypto.subtle.importKey( - "raw", - passphraseBytes, - { name: "PBKDF2" }, - false, - ["deriveKey"], - ); - - const normalizedIterations = Number.isFinite(iterations) - ? Math.max(1, Math.floor(iterations)) - : SESSION_ACTOR_KDF_ITERATIONS; - const normalizedHash = typeof hash === "string" && hash.trim() - ? hash.trim() - : SESSION_ACTOR_KDF_HASH; - - return globalThis.crypto.subtle.deriveKey( - { - name: "PBKDF2", - salt: saltBytes, - iterations: normalizedIterations, - hash: normalizedHash, - }, - baseKey, - { name: SESSION_ACTOR_ENCRYPTION_ALGORITHM, length: 256 }, - false, - ["encrypt", "decrypt"], - ); -} - -async function encryptSessionPrivateKey(privateKey, passphrase) { - if (typeof privateKey !== "string" || !privateKey.trim()) { - throw new Error("invalid-private-key"); - } - - if (typeof passphrase !== "string" || !passphrase.trim()) { - throw new Error("passphrase-required"); - } - - if (!isSubtleCryptoAvailable()) { - throw new Error("webcrypto-unavailable"); - } - - const encoder = typeof TextEncoder === "function" ? new TextEncoder() : null; - if (!encoder) { - throw new Error("text-encoder-unavailable"); - } - - const payload = encoder.encode(privateKey); - const salt = generateRandomBytes(SESSION_ACTOR_SALT_BYTES); - const iv = generateRandomBytes(SESSION_ACTOR_IV_BYTES); - const key = await deriveSessionEncryptionKey( - passphrase, - salt, - SESSION_ACTOR_KDF_ITERATIONS, - SESSION_ACTOR_KDF_HASH, - ); - - const ciphertext = await globalThis.crypto.subtle.encrypt( - { name: SESSION_ACTOR_ENCRYPTION_ALGORITHM, iv }, - key, - payload, - ); - - return { - ciphertext: arrayBufferToBase64(ciphertext), - salt: arrayBufferToBase64(salt), - iv: arrayBufferToBase64(iv), - iterations: SESSION_ACTOR_KDF_ITERATIONS, - hash: SESSION_ACTOR_KDF_HASH, - algorithm: SESSION_ACTOR_ENCRYPTION_ALGORITHM, - version: SESSION_ACTOR_ENCRYPTION_VERSION, - }; -} - -function normalizeStoredEncryptionMetadata(metadata) { - if (!metadata || typeof metadata !== "object") { - return null; - } - - const salt = typeof metadata.salt === "string" ? metadata.salt.trim() : ""; - const iv = typeof metadata.iv === "string" ? metadata.iv.trim() : ""; - if (!salt || !iv) { - return null; - } - - const iterations = Number.isFinite(metadata.iterations) - ? Math.max(1, Math.floor(metadata.iterations)) - : SESSION_ACTOR_KDF_ITERATIONS; - const version = Number.isFinite(metadata.version) - ? Math.max(1, Math.floor(metadata.version)) - : SESSION_ACTOR_ENCRYPTION_VERSION; - const algorithm = - typeof metadata.algorithm === "string" && metadata.algorithm.trim() - ? metadata.algorithm.trim() - : SESSION_ACTOR_ENCRYPTION_ALGORITHM; - const hash = - typeof metadata.hash === "string" && metadata.hash.trim() - ? metadata.hash.trim() - : SESSION_ACTOR_KDF_HASH; - - return { version, algorithm, salt, iv, iterations, hash }; -} - -async function decryptSessionPrivateKey(payload, passphrase) { - if (!payload || typeof payload !== "object") { - throw new Error("encrypted-session-invalid"); - } - - if (typeof passphrase !== "string" || !passphrase.trim()) { - throw new Error("passphrase-required"); - } - - if (!isSubtleCryptoAvailable()) { - throw new Error("webcrypto-unavailable"); - } - - const ciphertext = - typeof payload.privateKeyEncrypted === "string" - ? payload.privateKeyEncrypted.trim() - : ""; - const encryption = normalizeStoredEncryptionMetadata(payload.encryption); - - if (!ciphertext || !encryption) { - throw new Error("encrypted-session-invalid"); - } - - const cipherBytes = base64ToUint8Array(ciphertext); - const saltBytes = base64ToUint8Array(encryption.salt); - const ivBytes = base64ToUint8Array(encryption.iv); - if (!cipherBytes || !saltBytes || !ivBytes) { - throw new Error("encrypted-session-invalid"); - } - - const key = await deriveSessionEncryptionKey( - passphrase, - saltBytes, - encryption.iterations, - encryption.hash, - ); - - let decrypted; - try { - decrypted = await globalThis.crypto.subtle.decrypt( - { name: encryption.algorithm || SESSION_ACTOR_ENCRYPTION_ALGORITHM, iv: ivBytes }, - key, - cipherBytes, - ); - } catch (error) { - const failure = new Error("Failed to decrypt the stored private key."); - failure.code = "decrypt-failed"; - failure.cause = error; - throw failure; - } - - const decoder = typeof TextDecoder === "function" ? new TextDecoder() : null; - if (!decoder) { - throw new Error("text-decoder-unavailable"); - } - - return decoder.decode(decrypted); -} - -function readStoredSessionActorEntry() { - if (typeof localStorage === "undefined") { - return null; - } - - let raw = null; - try { - raw = localStorage.getItem(SESSION_ACTOR_STORAGE_KEY); - } catch (error) { - devLogger.warn("[nostr] Failed to read session actor from storage:", error); - return null; - } - - if (!raw || typeof raw !== "string") { - return null; - } - - try { - const parsed = JSON.parse(raw); - const pubkey = - typeof parsed?.pubkey === "string" ? parsed.pubkey.trim() : ""; - const privateKey = - typeof parsed?.privateKey === "string" ? parsed.privateKey.trim() : ""; - const privateKeyEncrypted = - typeof parsed?.privateKeyEncrypted === "string" - ? parsed.privateKeyEncrypted.trim() - : ""; - const encryption = normalizeStoredEncryptionMetadata(parsed?.encryption); - const createdAt = Number.isFinite(parsed?.createdAt) - ? parsed.createdAt - : Date.now(); - - return { - pubkey, - privateKey, - privateKeyEncrypted, - encryption, - createdAt, - }; - } catch (error) { - devLogger.warn("[nostr] Failed to parse stored session actor:", error); - try { - localStorage.removeItem(SESSION_ACTOR_STORAGE_KEY); - } catch (cleanupError) { - devLogger.warn( - "[nostr] Failed to clear corrupt session actor entry:", - cleanupError, - ); - } - } - - return null; -} - async function computeWatchHistoryFingerprintForItems(items) { const serialized = serializeWatchHistoryItems(items); const encoder = typeof TextEncoder === "function" ? new TextEncoder() : null; @@ -6608,55 +6071,18 @@ export class NostrClient { return { ok: false, error: new Error("extension-unavailable") }; } - if (typeof extension.enable !== "function") { + const enableResult = await requestEnablePermissions(extension, outstanding, { + isDevMode, + }); + + if (enableResult?.ok) { this.markExtensionPermissions(outstanding); - return { ok: true, code: "enable-unavailable" }; - } - - // Always request the full set of methods first so extensions surface the - // "All Access" prompt instead of defaulting to "Get Public Key" only. - const permissionVariants = []; - if (outstanding.length) { - permissionVariants.push({ - permissions: outstanding.map((method) => ({ method })), - }); - permissionVariants.push({ permissions: outstanding }); - } - permissionVariants.push(null); - - let lastError = null; - for (const options of permissionVariants) { - const variantTimeoutOverrides = options - ? { - timeoutMs: Math.min( - NIP07_LOGIN_TIMEOUT_MS, - getEnableVariantTimeoutMs(), - ), - retryMultiplier: 1, - } - : { retryMultiplier: 1 }; - - try { - await runNip07WithRetry( - () => (options ? extension.enable(options) : extension.enable()), - { label: "extension.enable", ...variantTimeoutOverrides }, - ); - this.markExtensionPermissions(outstanding); - return { ok: true }; - } catch (error) { - lastError = error; - if (options && isDevMode) { - userLogger.warn( - "[nostr] extension.enable request with explicit permissions failed:", - error, - ); - } - } + return enableResult; } return { ok: false, - error: lastError || new Error("permission-denied"), + error: enableResult?.error || new Error("permission-denied"), }; } @@ -6811,69 +6237,11 @@ export class NostrClient { } persistSessionActor(actor) { - if (typeof localStorage === "undefined") { - return; - } - - if ( - !actor || - typeof actor.pubkey !== "string" || - !actor.pubkey || - ((typeof actor.privateKey !== "string" || !actor.privateKey) && - (typeof actor.privateKeyEncrypted !== "string" || - !actor.privateKeyEncrypted)) - ) { - return; - } - - const createdAt = Number.isFinite(actor.createdAt) - ? actor.createdAt - : Date.now(); - - const payload = { - pubkey: actor.pubkey, - createdAt, - }; - - if (typeof actor.privateKey === "string" && actor.privateKey) { - payload.privateKey = actor.privateKey; - } else if ( - typeof actor.privateKeyEncrypted === "string" && - actor.privateKeyEncrypted && - actor.encryption && - typeof actor.encryption === "object" - ) { - const normalizedEncryption = normalizeStoredEncryptionMetadata( - actor.encryption, - ); - if (!normalizedEncryption) { - return; - } - payload.privateKeyEncrypted = actor.privateKeyEncrypted; - payload.encryption = normalizedEncryption; - } else { - return; - } - - try { - localStorage.setItem( - SESSION_ACTOR_STORAGE_KEY, - JSON.stringify(payload) - ); - } catch (error) { - devLogger.warn("[nostr] Failed to persist session actor:", error); - } + persistSessionActorEntry(actor); } clearStoredSessionActor() { - if (typeof localStorage === "undefined") { - return; - } - try { - localStorage.removeItem(SESSION_ACTOR_STORAGE_KEY); - } catch (error) { - devLogger.warn("[nostr] Failed to clear stored session actor:", error); - } + clearStoredSessionActorEntry(); } mintSessionActor() { @@ -13225,6 +12593,11 @@ function isNip04EncryptedWatchHistoryEvent(pointerEvent, ciphertext) { export const nostrClient = new NostrClient(); +export { + DEFAULT_NIP07_ENCRYPTION_METHODS, + DEFAULT_NIP07_PERMISSION_METHODS, +} from "./nostr/nip07Permissions.js"; + registerNostrClient(nostrClient, { requestPermissions: () => nostrClient.ensureExtensionPermissions(DEFAULT_NIP07_PERMISSION_METHODS), diff --git a/js/nostr/nip07Permissions.js b/js/nostr/nip07Permissions.js new file mode 100644 index 00000000..7e5f39aa --- /dev/null +++ b/js/nostr/nip07Permissions.js @@ -0,0 +1,312 @@ +import { devLogger, userLogger } from "../utils/logger.js"; + +export const NIP07_LOGIN_TIMEOUT_MS = 60_000; // 60 seconds +export const NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE = + "Timed out waiting for the NIP-07 extension. Confirm the extension prompt in your browser toolbar and try again."; +const NIP07_PERMISSIONS_STORAGE_KEY = "bitvid:nip07:permissions"; + +// Give the NIP-07 extension enough time to surface its approval prompt and let +// users unlock/authorize it. Seven seconds proved too aggressive once vendors +// started requiring an unlock step, so we extend the window substantially while +// still allowing manual overrides via __BITVID_NIP07_ENABLE_VARIANT_TIMEOUT_MS__. +const DEFAULT_ENABLE_VARIANT_TIMEOUT_MS = 45_000; + +export const DEFAULT_NIP07_ENCRYPTION_METHODS = Object.freeze([ + // Encryption helpers — request both legacy NIP-04 and modern NIP-44 upfront + "nip04.encrypt", + "nip04.decrypt", + "nip44.encrypt", + "nip44.decrypt", + "nip44.v2.encrypt", + "nip44.v2.decrypt", +]); + +export const DEFAULT_NIP07_PERMISSION_METHODS = Object.freeze([ + // Core auth + relay metadata + "get_public_key", + "sign_event", + "read_relays", + "write_relays", + ...DEFAULT_NIP07_ENCRYPTION_METHODS, +]); + +export function getEnableVariantTimeoutMs() { + const overrideValue = + typeof globalThis !== "undefined" && + globalThis !== null && + Number.isFinite(globalThis.__BITVID_NIP07_ENABLE_VARIANT_TIMEOUT_MS__) + ? Math.floor(globalThis.__BITVID_NIP07_ENABLE_VARIANT_TIMEOUT_MS__) + : null; + + if (overrideValue !== null && overrideValue > 0) { + return Math.max(50, overrideValue); + } + + return DEFAULT_ENABLE_VARIANT_TIMEOUT_MS; +} + +export function normalizePermissionMethod(method) { + return typeof method === "string" && method.trim() ? method.trim() : ""; +} + +function getNip07PermissionStorage() { + const scope = + typeof globalThis !== "undefined" && globalThis ? globalThis : undefined; + const browserWindow = + typeof window !== "undefined" && window ? window : undefined; + + if (browserWindow?.localStorage) { + return browserWindow.localStorage; + } + + if (scope?.localStorage) { + return scope.localStorage; + } + + return null; +} + +export function readStoredNip07Permissions() { + const storage = getNip07PermissionStorage(); + if (!storage) { + return new Set(); + } + + let rawValue = null; + try { + rawValue = storage.getItem(NIP07_PERMISSIONS_STORAGE_KEY); + } catch (error) { + return new Set(); + } + + if (!rawValue) { + return new Set(); + } + + try { + const parsed = JSON.parse(rawValue); + const storedMethods = Array.isArray(parsed) + ? parsed + : Array.isArray(parsed?.grantedMethods) + ? parsed.grantedMethods + : Array.isArray(parsed?.methods) + ? parsed.methods + : []; + return new Set( + storedMethods + .map((method) => normalizePermissionMethod(method)) + .filter(Boolean), + ); + } catch (error) { + clearStoredNip07Permissions(); + return new Set(); + } +} + +export function writeStoredNip07Permissions(methods) { + const storage = getNip07PermissionStorage(); + if (!storage) { + return; + } + + const normalized = Array.from( + new Set( + Array.from(methods || []) + .map((method) => normalizePermissionMethod(method)) + .filter(Boolean), + ), + ); + + try { + if (!normalized.length) { + storage.removeItem(NIP07_PERMISSIONS_STORAGE_KEY); + return; + } + + storage.setItem( + NIP07_PERMISSIONS_STORAGE_KEY, + JSON.stringify({ grantedMethods: normalized }), + ); + } catch (error) { + // ignore persistence failures + } +} + +export function clearStoredNip07Permissions() { + const storage = getNip07PermissionStorage(); + if (!storage) { + return; + } + + try { + storage.removeItem(NIP07_PERMISSIONS_STORAGE_KEY); + } catch (error) { + // ignore cleanup issues + } +} + +export function withNip07Timeout( + operation, + { + timeoutMs = NIP07_LOGIN_TIMEOUT_MS, + message = NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE, + } = {}, +) { + const numericTimeout = Number(timeoutMs); + const effectiveTimeout = + Number.isFinite(numericTimeout) && numericTimeout > 0 + ? numericTimeout + : NIP07_LOGIN_TIMEOUT_MS; + + let timeoutId; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new Error(message)); + }, effectiveTimeout); + }); + + let operationResult; + try { + operationResult = operation(); + } catch (err) { + if (timeoutId) { + clearTimeout(timeoutId); + } + throw err; + } + + const operationPromise = Promise.resolve(operationResult); + + return Promise.race([operationPromise, timeoutPromise]).finally(() => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }); +} + +export async function runNip07WithRetry( + operation, + { + label = "NIP-07 operation", + timeoutMs = NIP07_LOGIN_TIMEOUT_MS, + retryMultiplier = 2, + } = {}, +) { + let hasStarted = false; + let cachedPromise = null; + + const getOrStartOperation = () => { + if (!hasStarted) { + hasStarted = true; + try { + cachedPromise = Promise.resolve(operation()); + } catch (error) { + hasStarted = false; + cachedPromise = null; + throw error; + } + } + + return cachedPromise; + }; + + try { + return await withNip07Timeout(getOrStartOperation, { + timeoutMs, + message: NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE, + }); + } catch (error) { + const isTimeoutError = + error instanceof Error && + error.message === NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE; + + if (!isTimeoutError || retryMultiplier <= 1) { + throw error; + } + + const extendedTimeout = Math.max( + timeoutMs, + Math.round(timeoutMs * retryMultiplier), + ); + + devLogger.warn( + `[nostr] ${label} taking longer than ${timeoutMs}ms. Waiting up to ${extendedTimeout}ms for extension response.`, + ); + + return withNip07Timeout(getOrStartOperation, { + timeoutMs: extendedTimeout, + message: NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE, + }); + } +} + +export async function requestEnablePermissions( + extension, + outstandingMethods, + { isDevMode = false } = {}, +) { + if (!extension) { + return { ok: false, error: new Error("extension-unavailable") }; + } + + if (typeof extension.enable !== "function") { + return { ok: true, code: "enable-unavailable" }; + } + + const normalized = Array.isArray(outstandingMethods) + ? outstandingMethods + .map((method) => normalizePermissionMethod(method)) + .filter(Boolean) + : []; + + const permissionVariants = []; + if (normalized.length) { + permissionVariants.push({ + permissions: normalized.map((method) => ({ method })), + }); + permissionVariants.push({ permissions: normalized }); + } + permissionVariants.push(null); + + let lastError = null; + for (const options of permissionVariants) { + const variantTimeoutOverrides = options + ? { + timeoutMs: Math.min(NIP07_LOGIN_TIMEOUT_MS, getEnableVariantTimeoutMs()), + retryMultiplier: 1, + } + : { retryMultiplier: 1 }; + + try { + await runNip07WithRetry( + () => (options ? extension.enable(options) : extension.enable()), + { label: "extension.enable", ...variantTimeoutOverrides }, + ); + return { ok: true }; + } catch (error) { + lastError = error; + if (options && isDevMode) { + userLogger.warn( + "[nostr] extension.enable request with explicit permissions failed:", + error, + ); + } + } + } + + return { + ok: false, + error: lastError || new Error("permission-denied"), + }; +} + +export const __testExports = { + requestEnablePermissions, + runNip07WithRetry, + withNip07Timeout, + getEnableVariantTimeoutMs, + readStoredNip07Permissions, + writeStoredNip07Permissions, + clearStoredNip07Permissions, + normalizePermissionMethod, +}; diff --git a/js/nostr/sessionActor.js b/js/nostr/sessionActor.js new file mode 100644 index 00000000..02a5ea15 --- /dev/null +++ b/js/nostr/sessionActor.js @@ -0,0 +1,389 @@ +import { devLogger } from "../utils/logger.js"; + +export const SESSION_ACTOR_STORAGE_KEY = "bitvid:sessionActor:v1"; +export const SESSION_ACTOR_ENCRYPTION_VERSION = 1; +export const SESSION_ACTOR_KDF_ITERATIONS = 250_000; +export const SESSION_ACTOR_KDF_HASH = "SHA-256"; +export const SESSION_ACTOR_ENCRYPTION_ALGORITHM = "AES-GCM"; +export const SESSION_ACTOR_SALT_BYTES = 16; +export const SESSION_ACTOR_IV_BYTES = 12; + +function arrayBufferToBase64(buffer) { + if (!buffer) { + return ""; + } + + let view; + if (buffer instanceof ArrayBuffer) { + view = new Uint8Array(buffer); + } else if (ArrayBuffer.isView(buffer)) { + view = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength); + } else { + return ""; + } + + if (typeof globalThis?.btoa === "function") { + let binary = ""; + for (let index = 0; index < view.length; index += 1) { + binary += String.fromCharCode(view[index]); + } + return globalThis.btoa(binary); + } + + if (typeof Buffer !== "undefined") { + return Buffer.from(view).toString("base64"); + } + + return ""; +} + +function base64ToUint8Array(base64) { + if (typeof base64 !== "string" || !base64.trim()) { + return null; + } + + let binary; + try { + if (typeof globalThis?.atob === "function") { + binary = globalThis.atob(base64); + } else if (typeof Buffer !== "undefined") { + binary = Buffer.from(base64, "base64").toString("binary"); + } else { + return null; + } + } catch (error) { + return null; + } + + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; +} + +function generateRandomBytes(length) { + const size = Number.isFinite(length) ? Math.max(0, Math.floor(length)) : 0; + if (size <= 0) { + return new Uint8Array(0); + } + + if (globalThis?.crypto?.getRandomValues) { + const array = new Uint8Array(size); + globalThis.crypto.getRandomValues(array); + return array; + } + + throw new Error("secure-random-unavailable"); +} + +function isSubtleCryptoAvailable() { + return !!( + globalThis?.crypto?.subtle && + typeof globalThis.crypto.subtle.importKey === "function" + ); +} + +async function deriveSessionEncryptionKey(passphrase, saltBytes, iterations, hash) { + if (!isSubtleCryptoAvailable()) { + throw new Error("webcrypto-unavailable"); + } + + if (!(saltBytes instanceof Uint8Array)) { + throw new Error("invalid-salt"); + } + + const encoder = typeof TextEncoder === "function" ? new TextEncoder() : null; + if (!encoder) { + throw new Error("text-encoder-unavailable"); + } + + const passphraseBytes = encoder.encode(passphrase); + + const baseKey = await globalThis.crypto.subtle.importKey( + "raw", + passphraseBytes, + { name: "PBKDF2" }, + false, + ["deriveKey"], + ); + + const normalizedIterations = Number.isFinite(iterations) + ? Math.max(1, Math.floor(iterations)) + : SESSION_ACTOR_KDF_ITERATIONS; + const normalizedHash = typeof hash === "string" && hash.trim() + ? hash.trim() + : SESSION_ACTOR_KDF_HASH; + + return globalThis.crypto.subtle.deriveKey( + { + name: "PBKDF2", + salt: saltBytes, + iterations: normalizedIterations, + hash: normalizedHash, + }, + baseKey, + { name: SESSION_ACTOR_ENCRYPTION_ALGORITHM, length: 256 }, + false, + ["encrypt", "decrypt"], + ); +} + +export async function encryptSessionPrivateKey(privateKey, passphrase) { + if (typeof privateKey !== "string" || !privateKey.trim()) { + throw new Error("invalid-private-key"); + } + + if (typeof passphrase !== "string" || !passphrase.trim()) { + throw new Error("passphrase-required"); + } + + if (!isSubtleCryptoAvailable()) { + throw new Error("webcrypto-unavailable"); + } + + const encoder = typeof TextEncoder === "function" ? new TextEncoder() : null; + if (!encoder) { + throw new Error("text-encoder-unavailable"); + } + + const payload = encoder.encode(privateKey); + const salt = generateRandomBytes(SESSION_ACTOR_SALT_BYTES); + const iv = generateRandomBytes(SESSION_ACTOR_IV_BYTES); + const key = await deriveSessionEncryptionKey( + passphrase, + salt, + SESSION_ACTOR_KDF_ITERATIONS, + SESSION_ACTOR_KDF_HASH, + ); + + const ciphertext = await globalThis.crypto.subtle.encrypt( + { name: SESSION_ACTOR_ENCRYPTION_ALGORITHM, iv }, + key, + payload, + ); + + return { + ciphertext: arrayBufferToBase64(ciphertext), + salt: arrayBufferToBase64(salt), + iv: arrayBufferToBase64(iv), + iterations: SESSION_ACTOR_KDF_ITERATIONS, + hash: SESSION_ACTOR_KDF_HASH, + algorithm: SESSION_ACTOR_ENCRYPTION_ALGORITHM, + version: SESSION_ACTOR_ENCRYPTION_VERSION, + }; +} + +export function normalizeStoredEncryptionMetadata(metadata) { + if (!metadata || typeof metadata !== "object") { + return null; + } + + const salt = typeof metadata.salt === "string" ? metadata.salt.trim() : ""; + const iv = typeof metadata.iv === "string" ? metadata.iv.trim() : ""; + if (!salt || !iv) { + return null; + } + + const iterations = Number.isFinite(metadata.iterations) + ? Math.max(1, Math.floor(metadata.iterations)) + : SESSION_ACTOR_KDF_ITERATIONS; + const version = Number.isFinite(metadata.version) + ? Math.max(1, Math.floor(metadata.version)) + : SESSION_ACTOR_ENCRYPTION_VERSION; + const algorithm = + typeof metadata.algorithm === "string" && metadata.algorithm.trim() + ? metadata.algorithm.trim() + : SESSION_ACTOR_ENCRYPTION_ALGORITHM; + const hash = + typeof metadata.hash === "string" && metadata.hash.trim() + ? metadata.hash.trim() + : SESSION_ACTOR_KDF_HASH; + + return { version, algorithm, salt, iv, iterations, hash }; +} + +export async function decryptSessionPrivateKey(payload, passphrase) { + if (!payload || typeof payload !== "object") { + throw new Error("encrypted-session-invalid"); + } + + if (typeof passphrase !== "string" || !passphrase.trim()) { + throw new Error("passphrase-required"); + } + + if (!isSubtleCryptoAvailable()) { + throw new Error("webcrypto-unavailable"); + } + + const ciphertext = + typeof payload.privateKeyEncrypted === "string" + ? payload.privateKeyEncrypted.trim() + : ""; + const encryption = normalizeStoredEncryptionMetadata(payload.encryption); + + if (!ciphertext || !encryption) { + throw new Error("encrypted-session-invalid"); + } + + const cipherBytes = base64ToUint8Array(ciphertext); + const saltBytes = base64ToUint8Array(encryption.salt); + const ivBytes = base64ToUint8Array(encryption.iv); + if (!cipherBytes || !saltBytes || !ivBytes) { + throw new Error("encrypted-session-invalid"); + } + + const key = await deriveSessionEncryptionKey( + passphrase, + saltBytes, + encryption.iterations, + encryption.hash, + ); + + let decrypted; + try { + decrypted = await globalThis.crypto.subtle.decrypt( + { name: encryption.algorithm || SESSION_ACTOR_ENCRYPTION_ALGORITHM, iv: ivBytes }, + key, + cipherBytes, + ); + } catch (error) { + const failure = new Error("Failed to decrypt the stored private key."); + failure.code = "decrypt-failed"; + failure.cause = error; + throw failure; + } + + const decoder = typeof TextDecoder === "function" ? new TextDecoder() : null; + if (!decoder) { + throw new Error("text-decoder-unavailable"); + } + + return decoder.decode(decrypted); +} + +export function readStoredSessionActorEntry() { + if (typeof localStorage === "undefined") { + return null; + } + + let raw = null; + try { + raw = localStorage.getItem(SESSION_ACTOR_STORAGE_KEY); + } catch (error) { + devLogger.warn("[nostr] Failed to read session actor from storage:", error); + return null; + } + + if (!raw || typeof raw !== "string") { + return null; + } + + try { + const parsed = JSON.parse(raw); + const pubkey = + typeof parsed?.pubkey === "string" ? parsed.pubkey.trim() : ""; + const privateKey = + typeof parsed?.privateKey === "string" ? parsed.privateKey.trim() : ""; + const privateKeyEncrypted = + typeof parsed?.privateKeyEncrypted === "string" + ? parsed.privateKeyEncrypted.trim() + : ""; + const encryption = normalizeStoredEncryptionMetadata(parsed?.encryption); + const createdAt = Number.isFinite(parsed?.createdAt) + ? parsed.createdAt + : Date.now(); + + return { + pubkey, + privateKey, + privateKeyEncrypted, + encryption, + createdAt, + }; + } catch (error) { + devLogger.warn("[nostr] Failed to parse stored session actor:", error); + try { + localStorage.removeItem(SESSION_ACTOR_STORAGE_KEY); + } catch (cleanupError) { + devLogger.warn( + "[nostr] Failed to clear corrupt session actor entry:", + cleanupError, + ); + } + } + + return null; +} + +export function persistSessionActor(actor) { + if (typeof localStorage === "undefined") { + return; + } + + if ( + !actor || + typeof actor.pubkey !== "string" || + !actor.pubkey || + ((typeof actor.privateKey !== "string" || !actor.privateKey) && + (typeof actor.privateKeyEncrypted !== "string" || !actor.privateKeyEncrypted)) + ) { + return; + } + + const createdAt = Number.isFinite(actor.createdAt) + ? actor.createdAt + : Date.now(); + + const payload = { + pubkey: actor.pubkey, + createdAt, + }; + + if (typeof actor.privateKey === "string" && actor.privateKey) { + payload.privateKey = actor.privateKey; + } else if ( + typeof actor.privateKeyEncrypted === "string" && + actor.privateKeyEncrypted && + actor.encryption && + typeof actor.encryption === "object" + ) { + const normalizedEncryption = normalizeStoredEncryptionMetadata(actor.encryption); + if (!normalizedEncryption) { + return; + } + payload.privateKeyEncrypted = actor.privateKeyEncrypted; + payload.encryption = normalizedEncryption; + } else { + return; + } + + try { + localStorage.setItem( + SESSION_ACTOR_STORAGE_KEY, + JSON.stringify(payload), + ); + } catch (error) { + devLogger.warn("[nostr] Failed to persist session actor:", error); + } +} + +export function clearStoredSessionActor() { + if (typeof localStorage === "undefined") { + return; + } + + try { + localStorage.removeItem(SESSION_ACTOR_STORAGE_KEY); + } catch (error) { + devLogger.warn("[nostr] Failed to clear stored session actor:", error); + } +} + +export const __testExports = { + arrayBufferToBase64, + base64ToUint8Array, + deriveSessionEncryptionKey, + generateRandomBytes, + isSubtleCryptoAvailable, +}; diff --git a/scripts/run-unit-tests.mjs b/scripts/run-unit-tests.mjs index f5ac109d..e131fd94 100644 --- a/scripts/run-unit-tests.mjs +++ b/scripts/run-unit-tests.mjs @@ -27,7 +27,11 @@ async function collectTests(dir) { continue; } - if (entry.isFile() && entry.name.endsWith(".test.mjs")) { + const isTestFile = + entry.isFile() && + (entry.name.endsWith(".test.mjs") || entry.name.endsWith(".test.js")); + + if (isTestFile) { const content = await readFile(fullPath, "utf8"); if (content.includes("node:test")) { testFiles.push(fullPath); diff --git a/tests/nostr/nip07Permissions.test.js b/tests/nostr/nip07Permissions.test.js new file mode 100644 index 00000000..ad272181 --- /dev/null +++ b/tests/nostr/nip07Permissions.test.js @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + readStoredNip07Permissions, + writeStoredNip07Permissions, + clearStoredNip07Permissions, + requestEnablePermissions, +} from "../../js/nostr/nip07Permissions.js"; + +test("writeStoredNip07Permissions normalizes and persists granted methods", () => { + clearStoredNip07Permissions(); + + writeStoredNip07Permissions([" get_public_key ", "", "sign_event"]); + + const stored = readStoredNip07Permissions(); + assert.ok(stored instanceof Set); + + const sorted = [...stored].sort(); + assert.deepStrictEqual(sorted, ["get_public_key", "sign_event"]); +}); + +test("clearStoredNip07Permissions removes persisted grants", () => { + clearStoredNip07Permissions(); + writeStoredNip07Permissions(["get_public_key"]); + assert.notStrictEqual( + globalThis.localStorage.getItem("bitvid:nip07:permissions"), + null, + ); + + clearStoredNip07Permissions(); + + const afterClear = readStoredNip07Permissions(); + assert.strictEqual(afterClear.size, 0); + assert.strictEqual( + globalThis.localStorage.getItem("bitvid:nip07:permissions"), + null, + ); +}); + +test("requestEnablePermissions retries explicit and fallback variants", async () => { + clearStoredNip07Permissions(); + const calls = []; + let attempt = 0; + + const extension = { + enable: (options) => { + calls.push(options); + attempt += 1; + if (attempt < 3) { + return Promise.reject(new Error(`attempt-${attempt}`)); + } + return Promise.resolve(); + }, + }; + + const result = await requestEnablePermissions( + extension, + ["sign_event"], + { isDevMode: false }, + ); + + assert.deepStrictEqual(calls, [ + { permissions: [{ method: "sign_event" }] }, + { permissions: ["sign_event"] }, + undefined, + ]); + assert.deepStrictEqual(result, { ok: true }); +}); + +test("requestEnablePermissions reports unavailable extension", async () => { + clearStoredNip07Permissions(); + const result = await requestEnablePermissions(null, ["sign_event"]); + assert.strictEqual(result.ok, false); + assert.strictEqual(result.error?.message, "extension-unavailable"); +}); diff --git a/tests/nostr/sessionActor.test.js b/tests/nostr/sessionActor.test.js new file mode 100644 index 00000000..e0853881 --- /dev/null +++ b/tests/nostr/sessionActor.test.js @@ -0,0 +1,122 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { webcrypto } from "node:crypto"; + +const originalCrypto = globalThis.crypto; +if (originalCrypto === undefined) { + Object.defineProperty(globalThis, "crypto", { + value: webcrypto, + configurable: true, + writable: false, + enumerable: false, + }); +} + +import { + encryptSessionPrivateKey, + decryptSessionPrivateKey, + persistSessionActor, + readStoredSessionActorEntry, + clearStoredSessionActor, + SESSION_ACTOR_STORAGE_KEY, +} from "../../js/nostr/sessionActor.js"; + +test.after(() => { + clearStoredSessionActor(); + if (originalCrypto === undefined) { + delete globalThis.crypto; + } +}); + +test("encryptSessionPrivateKey + decryptSessionPrivateKey roundtrip", async () => { + clearStoredSessionActor(); + const privateKey = "a".repeat(64); + const passphrase = "correct horse"; + + const encrypted = await encryptSessionPrivateKey(privateKey, passphrase); + assert.match(encrypted.ciphertext, /.+/); + assert.ok(encrypted.salt.length > 0); + assert.ok(encrypted.iv.length > 0); + + const decrypted = await decryptSessionPrivateKey( + { + privateKeyEncrypted: encrypted.ciphertext, + encryption: encrypted, + }, + passphrase, + ); + + assert.strictEqual(decrypted, privateKey); +}); + +test("persistSessionActor stores plain-text session actors", () => { + clearStoredSessionActor(); + + persistSessionActor({ + pubkey: "npub1test", + privateKey: "b".repeat(64), + createdAt: 123, + }); + + const raw = globalThis.localStorage.getItem(SESSION_ACTOR_STORAGE_KEY); + assert.ok(raw, "session actor should be persisted"); + + const parsed = JSON.parse(raw); + assert.deepStrictEqual(parsed, { + pubkey: "npub1test", + privateKey: "b".repeat(64), + createdAt: 123, + }); +}); + +test("persistSessionActor stores encrypted payload metadata", async () => { + clearStoredSessionActor(); + + const privateKey = "c".repeat(64); + const passphrase = "battery staple"; + const encrypted = await encryptSessionPrivateKey(privateKey, passphrase); + + persistSessionActor({ + pubkey: "npub1encrypted", + privateKeyEncrypted: encrypted.ciphertext, + encryption: encrypted, + createdAt: 456, + }); + + const entry = readStoredSessionActorEntry(); + assert.ok(entry); + assert.strictEqual(entry.pubkey, "npub1encrypted"); + assert.strictEqual(entry.privateKey, ""); + assert.strictEqual(entry.privateKeyEncrypted, encrypted.ciphertext); + assert.deepStrictEqual(entry.encryption, { + version: encrypted.version, + algorithm: encrypted.algorithm, + salt: encrypted.salt, + iv: encrypted.iv, + iterations: encrypted.iterations, + hash: encrypted.hash, + }); + assert.strictEqual(entry.createdAt, 456); + + const raw = globalThis.localStorage.getItem(SESSION_ACTOR_STORAGE_KEY); + assert.ok(raw); + const parsed = JSON.parse(raw); + assert.strictEqual(parsed.privateKey, undefined); + assert.strictEqual(parsed.pubkey, "npub1encrypted"); + assert.strictEqual(parsed.privateKeyEncrypted, encrypted.ciphertext); +}); + +test("clearStoredSessionActor removes persisted payload", () => { + clearStoredSessionActor(); + persistSessionActor({ + pubkey: "npub1cleanup", + privateKey: "d".repeat(64), + }); + assert.ok(globalThis.localStorage.getItem(SESSION_ACTOR_STORAGE_KEY)); + + clearStoredSessionActor(); + assert.strictEqual( + globalThis.localStorage.getItem(SESSION_ACTOR_STORAGE_KEY), + null, + ); +});