Files
bitvid/js/nostr/sessionActor.js
T
thePR0M3TH3ANandClaude Opus 4.8 4795d5edbd [auth] Bug pass on login/signer/session surface: 4 fixes before promotion
Requested pre-promotion sweep of login methods, active signers, and anon session
actors. Four real bugs found and fixed:

1. Logging out could wipe EVERY saved account's remembered nsec key. The no-arg
   clearStoredSessionActor() removed the whole per-account v2 map, and it was
   reachable from SignerManager.logout() (non-persisted nsec cleanup) and from
   the blocked-key cleanup in registerPrivateKeySigner — same bug class as the
   NIP-46 session-map wipe fixed earlier. Now: the no-arg form clears only the
   legacy v1 slot; logout targets the departing account (and skips entirely when
   it can't name one); the blocked-key cleanup targets the blocked pubkey
   (mirroring the already-targeted twin in unlockStoredSessionActor).
   Spec-corrected the test that had encoded the wipe-all as intended behavior.

2. Removing a saved profile left its keys on the device. The switcher's
   remove-account flow cleared NWC + the keep-unlocked cache but kept the
   remembered (encrypted) nsec key and the NIP-46 session. New
   client.forgetStoredAccount(pubkey) (SignerManager.forgetStoredSessionActor +
   clearStoredNip46Session) forgets both WITHOUT touching the active session's
   in-memory actors; wired into handleProfileLogoutRequest.

3. Logout never actually re-locked a kept-unlocked device. The forget read
   detail.pubkey / this.pubkey — both already nulled by authService.logout()
   (the detail only carries previousPubkey) — so the cached decrypted key
   silently survived logout. Now reads previousPubkey.

4. restoreUnlockedSigner bypassed access control: a since-blacklisted account's
   cached key would silently restore its signer. It now accepts a validator
   (mirroring unlockStoredSessionActor), forgets the cached key on denial, and
   both app-level call sites (_initAutoLogin, ensureEncryptionCapableSigner)
   pass an accessControl.canAccess gate.

Also verified-safe during the pass (no action): logout's persisted-nsec keep
logic under the ephemeral session-actor overwrite; NIP-46 logout keepStored;
the ephemeral actor as a harmless extra DM-decrypt candidate; corrupt-map
recovery clears; anon blacklist-seeding now always has a signing-capable actor.

Tests: session-actor multi-account suite spec-corrected + re-verified (no-arg
clear keeps per-account keys); 57 tests across session-actor / nip46 /
unlocked-key / switch / signer-gate / feed-gate suites green. Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 21:59:52 -04:00

726 lines
21 KiB
JavaScript

import { devLogger } from "../utils/logger.js";
export const SESSION_ACTOR_STORAGE_KEY = "bitvid:sessionActor:v1";
// Per-pubkey encrypted-key store. Follows the existing `bitvid:<thing>:<npub>`
// convention (profileCache, nwcSettings, …) so several saved nsec accounts can
// each keep their own encrypted key on the device and be switched between. The
// v1 single slot above is kept in sync as the "active/last-saved" default and is
// migrated into this map on first read.
export const SESSION_ACTORS_MAP_STORAGE_KEY = "bitvid:sessionActors:v2";
export const SESSION_ACTORS_MAP_VERSION = 2;
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;
const SESSION_ACTOR_DB_NAME = "bitvidSessionActor";
const SESSION_ACTOR_DB_VERSION = 1;
const SESSION_ACTOR_STORE = "sessionActor";
let sessionActorDbPromise = null;
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 isIndexedDbAvailable() {
try {
return typeof indexedDB !== "undefined";
} catch (error) {
return false;
}
}
function openSessionActorDb() {
if (!isIndexedDbAvailable()) {
return Promise.resolve(null);
}
if (sessionActorDbPromise) {
return sessionActorDbPromise;
}
sessionActorDbPromise = new Promise((resolve, reject) => {
const request = indexedDB.open(SESSION_ACTOR_DB_NAME, SESSION_ACTOR_DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(SESSION_ACTOR_STORE)) {
db.createObjectStore(SESSION_ACTOR_STORE);
}
};
request.onsuccess = () => {
resolve(request.result);
};
request.onerror = () => {
reject(request.error || new Error("Failed to open session actor DB"));
};
});
return sessionActorDbPromise;
}
function persistSessionActorToIndexedDb(payload) {
return openSessionActorDb()
.then((db) => {
if (!db) {
return false;
}
return new Promise((resolve, reject) => {
const tx = db.transaction(SESSION_ACTOR_STORE, "readwrite");
const store = tx.objectStore(SESSION_ACTOR_STORE);
// Key each account's payload by pubkey so multiple saved accounts don't
// clobber one another (legacy single-slot records used the storage key).
store.put(payload, normalizePubkeyKey(payload?.pubkey) || SESSION_ACTOR_STORAGE_KEY);
tx.oncomplete = () => resolve(true);
tx.onerror = () =>
reject(tx.error || new Error("Session actor IndexedDB write failed"));
tx.onabort = () =>
reject(tx.error || new Error("Session actor IndexedDB write aborted"));
});
})
.catch((error) => {
devLogger.warn("[nostr] Failed to persist session actor to IndexedDB:", error);
return false;
});
}
function clearStoredSessionActorIndexedDb(pubkey) {
return openSessionActorDb()
.then((db) => {
if (!db) {
return false;
}
return new Promise((resolve, reject) => {
const tx = db.transaction(SESSION_ACTOR_STORE, "readwrite");
const store = tx.objectStore(SESSION_ACTOR_STORE);
const key = normalizePubkeyKey(pubkey);
if (key) {
// Forget just this account's key…
store.delete(key);
} else {
// …or wipe everything (logout / legacy single-slot record too).
store.clear();
}
tx.oncomplete = () => resolve(true);
tx.onerror = () =>
reject(tx.error || new Error("Session actor IndexedDB delete failed"));
tx.onabort = () =>
reject(tx.error || new Error("Session actor IndexedDB delete aborted"));
});
})
.catch((error) => {
devLogger.warn("[nostr] Failed to clear session actor IndexedDB entry:", error);
return false;
});
}
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);
}
function normalizePubkeyKey(pubkey) {
return typeof pubkey === "string" && pubkey.trim()
? pubkey.trim().toLowerCase()
: "";
}
// Turn a parsed stored payload into a canonical entry, or null if it lacks a
// usable encrypted key. Never exposes plaintext (privateKey is always "").
function normalizeStoredActorEntry(parsed) {
const pubkey = typeof parsed?.pubkey === "string" ? parsed.pubkey.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();
if (!privateKeyEncrypted || !encryption) {
return null;
}
return { pubkey, privateKey: "", privateKeyEncrypted, encryption, createdAt };
}
// Read the per-pubkey map { [pubkeyLower]: entry } from localStorage.
function readSessionActorsMap() {
if (typeof localStorage === "undefined") {
return {};
}
let raw = null;
try {
raw = localStorage.getItem(SESSION_ACTORS_MAP_STORAGE_KEY);
} catch (error) {
devLogger.warn("[nostr] Failed to read session actor map:", error);
return {};
}
if (!raw || typeof raw !== "string") {
return {};
}
try {
const parsed = JSON.parse(raw);
const entries =
parsed && typeof parsed === "object" ? parsed.entries : null;
if (!entries || typeof entries !== "object") {
return {};
}
const map = {};
for (const key of Object.keys(entries)) {
const entry = normalizeStoredActorEntry(entries[key]);
if (!entry) {
continue;
}
const normalizedKey = normalizePubkeyKey(entry.pubkey || key);
if (normalizedKey) {
map[normalizedKey] = entry;
}
}
return map;
} catch (error) {
devLogger.warn("[nostr] Failed to parse session actor map:", error);
try {
localStorage.removeItem(SESSION_ACTORS_MAP_STORAGE_KEY);
} catch (cleanupError) {
devLogger.warn(
"[nostr] Failed to clear corrupt session actor map:",
cleanupError,
);
}
return {};
}
}
function writeSessionActorsMap(map) {
if (typeof localStorage === "undefined") {
return;
}
const entries = {};
for (const key of Object.keys(map || {})) {
const entry = map[key];
if (!entry || !entry.privateKeyEncrypted || !entry.encryption) {
continue;
}
entries[key] = {
pubkey: entry.pubkey || key,
privateKeyEncrypted: entry.privateKeyEncrypted,
encryption: entry.encryption,
createdAt: Number.isFinite(entry.createdAt) ? entry.createdAt : Date.now(),
};
}
try {
if (!Object.keys(entries).length) {
localStorage.removeItem(SESSION_ACTORS_MAP_STORAGE_KEY);
return;
}
localStorage.setItem(
SESSION_ACTORS_MAP_STORAGE_KEY,
JSON.stringify({ version: SESSION_ACTORS_MAP_VERSION, entries }),
);
} catch (error) {
devLogger.warn("[nostr] Failed to persist session actor map:", error);
}
}
// Read the legacy single-slot (v1) entry. Also handles the legacy plaintext
// migration: a payload that still carries `privateKey` is re-persisted in the
// encrypted-only shape, or cleared if it can't be salvaged.
function readV1Entry() {
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 privateKeyEncrypted =
typeof parsed?.privateKeyEncrypted === "string"
? parsed.privateKeyEncrypted.trim()
: "";
const encryption = normalizeStoredEncryptionMetadata(parsed?.encryption);
const createdAt = Number.isFinite(parsed?.createdAt)
? parsed.createdAt
: Date.now();
if (parsed?.privateKey) {
if (privateKeyEncrypted && encryption) {
persistSessionActor({
pubkey,
privateKeyEncrypted,
encryption,
createdAt,
});
} else {
try {
localStorage.removeItem(SESSION_ACTOR_STORAGE_KEY);
} catch (cleanupError) {
devLogger.warn(
"[nostr] Failed to clear legacy session actor entry:",
cleanupError,
);
}
}
}
if (!privateKeyEncrypted || !encryption) {
return null;
}
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;
}
// Read a stored, passphrase-encrypted nsec entry.
// readStoredSessionActorEntry(pubkey) → that specific account's entry (or null)
// readStoredSessionActorEntry() → the active/last-saved account (v1 slot),
// for boot restore / backward-compat.
// The v1 single slot is migrated into the per-pubkey map on first read so
// existing single-account installs keep working and gain multi-account storage.
export function readStoredSessionActorEntry(pubkey) {
const v1 = readV1Entry();
const map = readSessionActorsMap();
// Migrate the legacy single slot into the per-pubkey map.
if (v1 && v1.pubkey) {
const key = normalizePubkeyKey(v1.pubkey);
if (key && !map[key]) {
map[key] = v1;
writeSessionActorsMap(map);
}
}
const target = normalizePubkeyKey(pubkey);
if (target) {
if (map[target]) {
return map[target];
}
if (v1 && normalizePubkeyKey(v1.pubkey) === target) {
return v1;
}
return null;
}
// No target: the active/last-saved account (v1 slot). Fall back to the sole
// stored account if the v1 slot was cleared but the map still has exactly one.
if (v1) {
return v1;
}
const keys = Object.keys(map);
return keys.length === 1 ? map[keys[0]] : null;
}
// Pubkeys (hex, lowercase) of every account with an encrypted key on this device.
export function listStoredSessionActorPubkeys() {
return Object.keys(readSessionActorsMap());
}
export function persistSessionActor(actor) {
if (
!actor ||
typeof actor.pubkey !== "string" ||
!actor.pubkey ||
typeof actor.privateKeyEncrypted !== "string" ||
!actor.privateKeyEncrypted ||
!actor.encryption ||
typeof actor.encryption !== "object"
) {
return;
}
const createdAt = Number.isFinite(actor.createdAt)
? actor.createdAt
: Date.now();
const payload = {
pubkey: actor.pubkey,
createdAt,
};
const normalizedEncryption = normalizeStoredEncryptionMetadata(actor.encryption);
if (!normalizedEncryption) {
return;
}
payload.privateKeyEncrypted = actor.privateKeyEncrypted;
payload.encryption = normalizedEncryption;
persistSessionActorToIndexedDb(payload);
// Store under the account's pubkey so several saved accounts coexist…
const key = normalizePubkeyKey(payload.pubkey);
if (key) {
const map = readSessionActorsMap();
map[key] = {
pubkey: payload.pubkey,
privateKeyEncrypted: payload.privateKeyEncrypted,
encryption: payload.encryption,
createdAt,
};
writeSessionActorsMap(map);
}
// …and keep the v1 single slot pointed at the last-saved account so boot
// restore and no-arg reads resolve a sensible default.
if (typeof localStorage !== "undefined") {
try {
localStorage.setItem(
SESSION_ACTOR_STORAGE_KEY,
JSON.stringify(payload),
);
} catch (error) {
devLogger.warn("[nostr] Failed to persist session actor:", error);
}
}
}
// Forget a stored key. With a pubkey, only that account is removed (the others
// stay switchable); without one, every stored account is wiped (logout).
export function clearStoredSessionActor(pubkey) {
const target = normalizePubkeyKey(pubkey);
if (target) {
clearStoredSessionActorIndexedDb(target);
const map = readSessionActorsMap();
if (map[target]) {
delete map[target];
writeSessionActorsMap(map);
}
// Drop the v1 default only if it pointed at the account we just removed.
if (typeof localStorage !== "undefined") {
const v1 = readV1Entry();
if (v1 && normalizePubkeyKey(v1.pubkey) === target) {
try {
localStorage.removeItem(SESSION_ACTOR_STORAGE_KEY);
} catch (error) {
devLogger.warn("[nostr] Failed to clear stored session actor:", error);
}
}
}
return;
}
// No target pubkey: clear ONLY the legacy v1 "last saved" slot. Wiping the
// per-account v2 map here deleted EVERY saved account's remembered nsec key —
// the no-arg callers were logout/teardown paths for ONE account, so logging
// out (or a blocked-key cleanup) silently destroyed the other saved accounts'
// keys and broke switching back to them. Same bug class as the NIP-46
// session-map wipe. A specific account is forgotten via
// clearStoredSessionActor(pubkey), which remains targeted.
if (typeof localStorage !== "undefined") {
try {
localStorage.removeItem(SESSION_ACTOR_STORAGE_KEY);
} catch (error) {
devLogger.warn("[nostr] Failed to clear stored session actor:", error);
}
}
}
export function isSessionActor(nostrClient) {
const sa = nostrClient?.sessionActor;
if (!sa || sa.source === "nsec") {
return false;
}
const clientPubkey =
typeof nostrClient.pubkey === "string" ? nostrClient.pubkey : "";
const saPubkey = typeof sa.pubkey === "string" ? sa.pubkey : "";
return clientPubkey === saPubkey;
}
function _closeSessionActorDb() {
if (sessionActorDbPromise) {
return sessionActorDbPromise.then((db) => {
db.close();
sessionActorDbPromise = null;
});
}
return Promise.resolve();
}
export const __testExports = {
arrayBufferToBase64,
base64ToUint8Array,
deriveSessionEncryptionKey,
generateRandomBytes,
isSubtleCryptoAvailable,
_closeSessionActorDb,
};