mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Refactor NIP-07 helpers and session actor storage
This commit is contained in:
1 parent
35ac62be15
commit
3382185512
6 files changed
+935
-659
No files matched your search
+31
-658
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user