mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
[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:
1 parent
8692dac671
commit
95fe88784a
6 files changed
+334
-15
No files matched your search
+2
-2
@@ -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(
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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)),
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user