[nostr-core] Per-account NIP-46 session storage for multi-account switching (#48)

Mirror the per-account nsec key store for remote-signer sessions: move from a
single slot (bitvid:nip46:session:v1) to a per-user-pubkey map
(bitvid:nip46:sessions:v2) so several saved NIP-46 accounts each keep their own
reconnectable session and the profile switcher can move between them.
Previously only the last-connected session survived, so switching among saved
NIP-46 accounts reconnected the wrong one (or failed).

- nip46Client.js: new per-pubkey session map keyed by userPubkey.
  readStoredNip46Session takes an optional target pubkey; writeStoredNip46-
  SessionSync writes both the map and the v1 last-connected default;
  clearStoredNip46Session(pubkey) forgets one account (no-arg wipes all). Legacy
  v1 slot migrated into the map on first read. Adds listStoredNip46SessionPubkeys().
- SignerManager: useStoredRemoteSigner({ pubkey }) selects the target account's
  session and, on access-denial, forgets only that account. getStoredNip46-
  Metadata(pubkey) is per-account.
- nip46 provider threads expectPubkey into the reuse call so a switch reconnects
  the requested account, with the expectPubkey guard as a backstop.

nip46Connector.js is dead code; the live path is entirely in SignerManager.
Tests: tests/nip46-multi-account.test.mjs (7).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-30 22:40:22 -04:00
1 parent 8692dac671
commit 95fe88784a
6 files changed
+334 -15

No files matched your search

+2 -2
View File
@@ -2919,8 +2919,8 @@ export class NostrClient {
return this.signerManager.onRemoteSignerChange(listener);
}
getStoredNip46Metadata() {
return this.signerManager.getStoredNip46Metadata();
getStoredNip46Metadata(pubkey) {
return this.signerManager.getStoredNip46Metadata(pubkey);
}
async ensureExtensionPermissions(
+12 -5
View File
@@ -778,8 +778,14 @@ export class SignerManager {
typeof normalizedOptions.validator === "function"
? normalizedOptions.validator
: null;
// When switching accounts, select that account's stored session so we
// reconnect the one the user asked for — not just the last-connected default.
const targetPubkey =
typeof normalizedOptions.pubkey === "string"
? normalizedOptions.pubkey.trim().toLowerCase()
: "";
let stored = readStoredNip46Session();
let stored = readStoredNip46Session(targetPubkey || undefined);
if (!stored) {
const error = new Error(
"No remote signer session is stored on this device.",
@@ -850,9 +856,10 @@ export class SignerManager {
this.nip46Client = null;
this.emitRemoteSignerChange({ state: "error", error: err });
// Always forget a stored session that was rejected by access control —
// no point keeping a blocked pubkey's session to retry.
// no point keeping a blocked pubkey's session to retry. Forget only the
// account we tried, leaving other saved sessions switchable.
if (forgetOnError || err?.code === "remote-signer-access-denied") {
clearStoredNip46Session();
clearStoredNip46Session(stored?.userPubkey || targetPubkey || undefined);
}
throw err;
}
@@ -947,8 +954,8 @@ export class SignerManager {
this.emitRemoteSignerChange();
}
getStoredNip46Metadata() {
const stored = readStoredNip46Session() || {};
getStoredNip46Metadata(pubkey) {
const stored = readStoredNip46Session(pubkey) || {};
const userPubkey =
typeof stored.userPubkey === "string" ? stored.userPubkey : "";
return {
+171 -2
View File
@@ -46,6 +46,13 @@ import { SHORT_TIMEOUT_MS } from "../constants.js";
export const NIP46_RPC_KIND = 24_133;
const NIP46_SESSION_STORAGE_KEY = "bitvid:nip46:session:v1";
// Per-user-pubkey remote-signer session store, mirroring the per-account nsec
// key store (bitvid:sessionActors:v2). Lets several saved NIP-46 accounts each
// keep their own reconnectable session so the profile switcher can move between
// them. The v1 single slot above is kept in sync as the last-connected default
// (boot restore) and migrated into this map on first read.
const NIP46_SESSIONS_MAP_STORAGE_KEY = "bitvid:nip46:sessions:v2";
const NIP46_SESSIONS_MAP_VERSION = 2;
const NIP46_PUBLISH_TIMEOUT_MS = 8_000;
const NIP46_PING_TIMEOUT_MS = SHORT_TIMEOUT_MS;
const NIP46_RESPONSE_TIMEOUT_MS = 15_000;
@@ -248,7 +255,88 @@ export function sanitizeStoredNip46Session(candidate) {
};
}
export function readStoredNip46Session() {
function normalizeNip46PubkeyKey(pubkey) {
return typeof pubkey === "string" && pubkey.trim()
? pubkey.trim().toLowerCase()
: "";
}
// Read the per-user-pubkey session map { [userPubkeyLower]: session }.
function readNip46SessionsMap() {
const storage = getNip46Storage();
if (!storage) {
return {};
}
let raw = null;
try {
raw = storage.getItem(NIP46_SESSIONS_MAP_STORAGE_KEY);
} catch (error) {
return {};
}
if (!raw) {
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 session = sanitizeStoredNip46Session(entries[key]);
if (!session) {
continue;
}
const normalizedKey = normalizeNip46PubkeyKey(session.userPubkey || key);
if (normalizedKey) {
map[normalizedKey] = session;
}
}
return map;
} catch (error) {
try {
storage.removeItem(NIP46_SESSIONS_MAP_STORAGE_KEY);
} catch (cleanupError) {
devLogger.warn(
"[nostr] Failed to clear corrupt NIP-46 session map:",
cleanupError,
);
}
return {};
}
}
function writeNip46SessionsMap(map) {
const storage = getNip46Storage();
if (!storage) {
return;
}
const entries = {};
for (const key of Object.keys(map || {})) {
const session = sanitizeStoredNip46Session(map[key]);
if (session) {
entries[key] = session;
}
}
try {
if (!Object.keys(entries).length) {
storage.removeItem(NIP46_SESSIONS_MAP_STORAGE_KEY);
return;
}
storage.setItem(
NIP46_SESSIONS_MAP_STORAGE_KEY,
JSON.stringify({ version: NIP46_SESSIONS_MAP_VERSION, entries }),
);
} catch (error) {
// ignore persistence failures
}
}
// Read the legacy v1 single slot, re-persisting it in the sanitized (no
// plaintext) shape if it still carries raw credentials.
function readV1Nip46Session() {
const storage = getNip46Storage();
if (!storage) {
return null;
@@ -289,6 +377,49 @@ export function readStoredNip46Session() {
}
}
// Read a stored remote-signer session.
// readStoredNip46Session(pubkey) → that account's session (or null)
// readStoredNip46Session() → the last-connected account (v1 slot),
// for boot restore / backward-compat.
// The v1 single slot is migrated into the per-pubkey map on first read.
export function readStoredNip46Session(pubkey) {
const v1 = readV1Nip46Session();
const map = readNip46SessionsMap();
// Migrate the legacy single slot into the per-pubkey map.
if (v1 && v1.userPubkey) {
const key = normalizeNip46PubkeyKey(v1.userPubkey);
if (key && !map[key]) {
map[key] = v1;
writeNip46SessionsMap(map);
}
}
const target = normalizeNip46PubkeyKey(pubkey);
if (target) {
if (map[target]) {
return map[target];
}
if (v1 && normalizeNip46PubkeyKey(v1.userPubkey) === target) {
return v1;
}
return null;
}
// No target: the last-connected account. Fall back to the sole stored session
// if the v1 slot was cleared but the map still holds exactly one.
if (v1) {
return v1;
}
const keys = Object.keys(map);
return keys.length === 1 ? map[keys[0]] : null;
}
// User pubkeys (hex, lowercase) of every account with a stored session.
export function listStoredNip46SessionPubkeys() {
return Object.keys(readNip46SessionsMap());
}
export function writeStoredNip46SessionSync(payload) {
const storage = getNip46Storage();
if (!storage) {
@@ -321,6 +452,16 @@ export function writeStoredNip46SessionSync(payload) {
return;
}
// Store under the account's user pubkey so several saved accounts coexist…
const key = normalizeNip46PubkeyKey(normalized.userPubkey);
if (key) {
const map = readNip46SessionsMap();
map[key] = normalized;
writeNip46SessionsMap(map);
}
// …and keep the v1 single slot pointed at the last-connected account so boot
// restore and no-arg reads resolve a sensible default.
try {
storage.setItem(NIP46_SESSION_STORAGE_KEY, JSON.stringify(normalized));
} catch (error) {
@@ -412,17 +553,45 @@ export async function decryptNip46Session(session, passphrase) {
};
}
export function clearStoredNip46Session() {
// Forget a stored session. With a pubkey, only that account is removed (the
// others stay reconnectable); without one, every stored session is wiped
// (logout).
export function clearStoredNip46Session(pubkey) {
const storage = getNip46Storage();
if (!storage) {
return;
}
const target = normalizeNip46PubkeyKey(pubkey);
if (target) {
const map = readNip46SessionsMap();
if (map[target]) {
delete map[target];
writeNip46SessionsMap(map);
}
// Drop the v1 default only if it pointed at the account we just removed.
const v1 = readV1Nip46Session();
if (v1 && normalizeNip46PubkeyKey(v1.userPubkey) === target) {
try {
storage.removeItem(NIP46_SESSION_STORAGE_KEY);
} catch (error) {
// ignore cleanup issues
}
}
return;
}
try {
storage.removeItem(NIP46_SESSION_STORAGE_KEY);
} catch (error) {
// ignore cleanup issues
}
try {
storage.removeItem(NIP46_SESSIONS_MAP_STORAGE_KEY);
} catch (error) {
// ignore cleanup issues
}
}
export function decodeNpubToHex(npub) {
+8 -1
View File
@@ -97,6 +97,10 @@ function normalizeOptions(options) {
handshakeTimeoutMs: Number.isFinite(options.handshakeTimeoutMs)
? Number(options.handshakeTimeoutMs)
: undefined,
// Set when switching to a specific saved account, so reusing a stored
// session targets that account rather than the last-connected default.
expectPubkey:
typeof options.expectPubkey === "string" ? options.expectPubkey.trim() : "",
};
}
@@ -163,7 +167,10 @@ export default {
// For now, I will assume I will fix client.js later or accept that stored sessions are trusted?
// No, I must fix useStoredRemoteSigner too.
result = await nostrClient.useStoredRemoteSigner({ validator });
result = await nostrClient.useStoredRemoteSigner({
validator,
pubkey: normalized.expectPubkey || undefined,
});
devLogger.debug("[nip46] Stored remote signer returned", {
pubkey: summarizeHexForLog(normalizePubkey(result)),
});
+127
View File
@@ -0,0 +1,127 @@
// Per-account NIP-46 remote-signer session storage: several saved NIP-46
// accounts each keep their own reconnectable session so the profile switcher
// can move between them. Before this, a single slot (bitvid:nip46:session:v1)
// meant only the last-connected session survived, so switching among saved
// NIP-46 accounts silently reconnected the wrong one (or failed).
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-nip46-multi-account-storage
// given: "two NIP-46 accounts each persist a stored session"
// when: "the store is read by user pubkey / cleared for one account"
// then: "each account resolves its own session; clearing one keeps the other"
// observable_outcomes:
// - "readStoredNip46Session(pubkey) returns that account's remotePubkey/relays"
// - "clearStoredNip46Session(pubkey) removes only the named account"
// - "the legacy v1 single slot migrates into the per-pubkey map on read"
// determinism_controls:
// - "in-memory localStorage polyfill; no network/clock dependence"
// anti_cheat_rationale:
// prevents: ["hard-coded return value", "over-mocking internal logic"]
// relaxation:
// did_relax_any_assertion: false
import "./test-helpers/setup-localstorage.mjs";
import test, { beforeEach } from "node:test";
import { strict as assert } from "node:assert";
import {
writeStoredNip46SessionSync,
readStoredNip46Session,
clearStoredNip46Session,
listStoredNip46SessionPubkeys,
} from "../js/nostr/nip46Client.js";
const USER_A =
"00000000000000000000000000000000000000000000000000000000000000a1";
const USER_B =
"00000000000000000000000000000000000000000000000000000000000000b2";
const REMOTE_A =
"1111111111111111111111111111111111111111111111111111111111111111";
const REMOTE_B =
"2222222222222222222222222222222222222222222222222222222222222222";
const NIP46_SESSION_STORAGE_KEY = "bitvid:nip46:session:v1";
const NIP46_SESSIONS_MAP_STORAGE_KEY = "bitvid:nip46:sessions:v2";
function session(userPubkey, remotePubkey, relay) {
return {
version: 1,
clientPublicKey:
"3333333333333333333333333333333333333333333333333333333333333333",
remotePubkey,
relays: [relay],
encryption: "nip44.v2",
userPubkey,
lastConnectedAt: 1700000000000,
encryptedSecrets: "cipher",
keyEncryption: { salt: "c2FsdA==", iv: "aXY=", iterations: 1000, hash: "SHA-256" },
};
}
beforeEach(() => {
localStorage.clear();
});
test("each saved account resolves its own session", () => {
writeStoredNip46SessionSync(session(USER_A, REMOTE_A, "wss://a.example"));
writeStoredNip46SessionSync(session(USER_B, REMOTE_B, "wss://b.example"));
assert.equal(readStoredNip46Session(USER_A).remotePubkey, REMOTE_A);
assert.equal(readStoredNip46Session(USER_B).remotePubkey, REMOTE_B);
assert.deepEqual(readStoredNip46Session(USER_A).relays, ["wss://a.example"]);
});
test("lists every account that has a stored session", () => {
writeStoredNip46SessionSync(session(USER_A, REMOTE_A, "wss://a.example"));
writeStoredNip46SessionSync(session(USER_B, REMOTE_B, "wss://b.example"));
assert.deepEqual(listStoredNip46SessionPubkeys().sort(), [USER_A, USER_B].sort());
});
test("clearing one account keeps the others reconnectable", () => {
writeStoredNip46SessionSync(session(USER_A, REMOTE_A, "wss://a.example"));
writeStoredNip46SessionSync(session(USER_B, REMOTE_B, "wss://b.example"));
clearStoredNip46Session(USER_A);
assert.equal(readStoredNip46Session(USER_A), null);
assert.equal(readStoredNip46Session(USER_B).remotePubkey, REMOTE_B);
});
test("no-arg read returns the last-connected account as the default", () => {
writeStoredNip46SessionSync(session(USER_A, REMOTE_A, "wss://a.example"));
writeStoredNip46SessionSync(session(USER_B, REMOTE_B, "wss://b.example"));
assert.equal(readStoredNip46Session().remotePubkey, REMOTE_B);
});
test("no-arg clear wipes every stored session (logout)", () => {
writeStoredNip46SessionSync(session(USER_A, REMOTE_A, "wss://a.example"));
writeStoredNip46SessionSync(session(USER_B, REMOTE_B, "wss://b.example"));
clearStoredNip46Session();
assert.deepEqual(listStoredNip46SessionPubkeys(), []);
assert.equal(readStoredNip46Session(USER_A), null);
assert.equal(readStoredNip46Session(), null);
});
test("a legacy v1 single-slot session migrates into the per-pubkey map", () => {
// Simulate an install that only ever had the old single slot.
localStorage.setItem(
NIP46_SESSION_STORAGE_KEY,
JSON.stringify(session(USER_A, REMOTE_A, "wss://a.example")),
);
assert.equal(localStorage.getItem(NIP46_SESSIONS_MAP_STORAGE_KEY), null);
assert.equal(readStoredNip46Session(USER_A).remotePubkey, REMOTE_A);
assert.deepEqual(listStoredNip46SessionPubkeys(), [USER_A]);
// A second account can now be added alongside the migrated one.
writeStoredNip46SessionSync(session(USER_B, REMOTE_B, "wss://b.example"));
assert.equal(readStoredNip46Session(USER_A).remotePubkey, REMOTE_A);
assert.equal(readStoredNip46Session(USER_B).remotePubkey, REMOTE_B);
});
test("requesting an account with no stored session returns null (not a wrong one)", () => {
writeStoredNip46SessionSync(session(USER_A, REMOTE_A, "wss://a.example"));
assert.equal(readStoredNip46Session(USER_B), null);
});
+14 -5
View File
@@ -635,11 +635,20 @@ toward freshness and looked identical. Gave each a structural identity:
`listStoredSessionActorPubkeys()`. Tests: `tests/session-actor-multi-account.test.mjs`
(7 — per-account resolve, list, clear-one-keeps-others, no-arg default, no-arg wipe,
v1→v2 migration, missing-account returns null).
- [ ] **Multiple NIP-46 accounts (remaining):** the remote-signer session is still a
SINGLE slot (`bitvid:nip46:session:v1`), so only the last-connected remote signer is
restorable — switching among several saved NIP-46 accounts needs the same per-pubkey
treatment applied to `js/nostr/nip46Client.js` (session store keyed by user pubkey +
`useStoredRemoteSigner({ pubkey })` selecting it). Separate, larger change than nsec.
- [x] **Multiple NIP-46 accounts — DONE 2026-06-30.** The remote-signer session store is
now per-user-pubkey (`bitvid:nip46:sessions:v2` = `{ [userPubkeyLower]: session }`),
mirroring the nsec store. Several saved NIP-46 accounts each keep their own
reconnectable session, so the switcher can move between them. The legacy v1 single
slot is kept in sync as the last-connected default (boot restore) and migrated into
the map on first read. `readStoredNip46Session` and `getStoredNip46Metadata` take an
optional target pubkey; `writeStoredNip46SessionSync` writes both the map (keyed by
`userPubkey`) and the v1 default; `clearStoredNip46Session(pubkey)` forgets one
account (no-arg wipes all, for logout). `SignerManager.useStoredRemoteSigner({ pubkey
})` selects the target account's session and, on access-denial, forgets only that
account; the nsec/nip46 providers thread `expectPubkey` through so a switch
reconnects the requested account (with the `expectPubkey` guard as a backstop). Added
`listStoredNip46SessionPubkeys()`. (`nip46Connector.js` is dead code — the live path
is entirely in `SignerManager`.) Tests: `tests/nip46-multi-account.test.mjs` (7).
### 8. Orphan storage garbage-collection tool
- [x] **Largely delivered by the My Videos tab** (`9d3a0df0`): lists bucket objects no