Merge pull request #1318 from PR0M3TH3AN/codex/connect-persistence-helpers-for-nsec-and-nip-46

Harden signer session persistence and NIP-46 metadata storage
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-01-18 11:13:51 -05:00
commit 4a7a1e036a
4 files changed
+217 -74

No files matched your search

+26 -24
View File
@@ -168,6 +168,7 @@ import {
setActiveSigner as setActiveSignerInRegistry,
getActiveSigner as getActiveSignerFromRegistry,
clearActiveSigner as clearActiveSignerInRegistry,
logoutSigner as logoutSignerFromRegistry,
resolveActiveSigner as resolveActiveSignerFromRegistry,
} from "../nostrClientRegistry.js";
import { createNip07Adapter } from "./adapters/nip07Adapter.js";
@@ -346,6 +347,10 @@ function clearActiveSigner() {
clearActiveSignerInRegistry();
}
function logoutSigner(pubkey) {
logoutSignerFromRegistry(pubkey);
}
function resolveActiveSigner(pubkey) {
const signer = resolveActiveSignerFromRegistry(pubkey);
hydrateExtensionSignerCapabilities(signer);
@@ -1199,9 +1204,11 @@ export class NostrClient {
const remotePubkey = stored.remotePubkey || "";
const userPubkey = stored.userPubkey || "";
const canReconnect = Boolean(stored.clientPrivateKey && stored.secret);
return {
hasSession: true,
canReconnect,
remotePubkey,
remoteNpub: encodeHexToNpub(remotePubkey),
clientPublicKey: stored.clientPublicKey || "",
@@ -2068,12 +2075,10 @@ export class NostrClient {
});
writeStoredNip46Session({
version: 1,
clientPrivateKey,
clientPublicKey,
remotePubkey,
relays,
encryption: client.encryptionAlgorithm || handshakeAlgorithm || "",
secret,
permissions,
metadata,
userPubkey,
@@ -2155,7 +2160,7 @@ export class NostrClient {
relays,
encryption: stored.encryption || "",
permissions: stored.permissions || null,
hasSecret: Boolean(stored.secret),
hasCredentials: Boolean(stored.clientPrivateKey && stored.secret),
});
this.emitRemoteSignerChange({
@@ -2165,6 +2170,12 @@ export class NostrClient {
metadata: stored.metadata,
});
if (!stored.clientPrivateKey || !stored.secret) {
const error = new Error("Stored remote signer credentials are unavailable.");
error.code = "stored-session-missing-credentials";
throw error;
}
const client = new Nip46RpcClient({
nostrClient: this,
clientPrivateKey: stored.clientPrivateKey,
@@ -2186,8 +2197,13 @@ export class NostrClient {
const signer = await this.installNip46Client(client, { userPubkey });
writeStoredNip46Session({
...stored,
version: 1,
clientPublicKey: stored.clientPublicKey,
remotePubkey: stored.remotePubkey,
relays: stored.relays,
encryption: client.encryptionAlgorithm || stored.encryption || "",
permissions: stored.permissions,
metadata: stored.metadata,
userPubkey,
lastConnectedAt: Date.now(),
});
@@ -2262,7 +2278,7 @@ export class NostrClient {
}
const stored = this.getStoredNip46Metadata();
if (!stored.hasSession) {
if (!stored.hasSession || !stored.canReconnect) {
return null;
}
@@ -2289,7 +2305,7 @@ export class NostrClient {
const activeSigner = getActiveSigner();
if (activeSigner?.type === "nip46") {
clearActiveSigner();
logoutSigner(activeSigner.pubkey);
}
if (!keepStored) {
@@ -2642,14 +2658,7 @@ export class NostrClient {
return null;
}
const { pubkey, privateKey, privateKeyEncrypted, encryption, createdAt } = entry;
if (privateKey && HEX64_REGEX.test(privateKey)) {
return {
pubkey,
privateKey: privateKey.toLowerCase(),
createdAt: Number.isFinite(createdAt) ? createdAt : Date.now(),
};
}
const { pubkey, privateKeyEncrypted, encryption, createdAt } = entry;
if (privateKeyEncrypted && encryption) {
this.lockedSessionActor = {
@@ -2672,7 +2681,7 @@ export class NostrClient {
return null;
}
const { pubkey, privateKeyEncrypted, encryption, createdAt, privateKey } = entry;
const { pubkey, privateKeyEncrypted, encryption, createdAt } = entry;
const normalizedCreatedAt = Number.isFinite(createdAt)
? createdAt
: Date.now();
@@ -2693,14 +2702,6 @@ export class NostrClient {
this.lockedSessionActor = null;
if (privateKey) {
return {
pubkey,
hasEncryptedKey: false,
createdAt: normalizedCreatedAt,
};
}
if (pubkey) {
return {
pubkey,
@@ -3878,8 +3879,9 @@ export class NostrClient {
}
logout() {
const previousPubkey = this.pubkey;
this.pubkey = null;
clearActiveSigner();
logoutSigner(previousPubkey);
const previousSessionActor = this.sessionActor;
this.sessionActor = null;
this.sessionActorCipherClosures = null;
+25 -12
View File
@@ -95,10 +95,6 @@ export function sanitizeStoredNip46Session(candidate) {
return null;
}
const clientPrivateKey =
typeof candidate.clientPrivateKey === "string" && HEX64_REGEX.test(candidate.clientPrivateKey)
? candidate.clientPrivateKey.toLowerCase()
: "";
const clientPublicKey =
typeof candidate.clientPublicKey === "string" && candidate.clientPublicKey.trim()
? candidate.clientPublicKey.trim().toLowerCase()
@@ -108,7 +104,7 @@ export function sanitizeStoredNip46Session(candidate) {
? candidate.remotePubkey.trim().toLowerCase()
: "";
if (!clientPrivateKey || !remotePubkey) {
if (!remotePubkey) {
return null;
}
@@ -138,7 +134,6 @@ export function sanitizeStoredNip46Session(candidate) {
return {
version: 1,
clientPrivateKey,
clientPublicKey,
remotePubkey,
relays,
@@ -149,10 +144,6 @@ export function sanitizeStoredNip46Session(candidate) {
? candidate.algorithm
: "",
),
secret:
typeof candidate.secret === "string" && candidate.secret.trim()
? candidate.secret.trim()
: "",
permissions:
typeof candidate.permissions === "string" && candidate.permissions.trim()
? candidate.permissions.trim()
@@ -187,7 +178,15 @@ export function readStoredNip46Session() {
try {
const parsed = JSON.parse(raw);
return sanitizeStoredNip46Session(parsed);
const sanitized = sanitizeStoredNip46Session(parsed);
if (
sanitized &&
(typeof parsed?.clientPrivateKey === "string" ||
typeof parsed?.secret === "string")
) {
writeStoredNip46Session(sanitized);
}
return sanitized;
} catch (error) {
try {
storage.removeItem(NIP46_SESSION_STORAGE_KEY);
@@ -207,7 +206,21 @@ export function writeStoredNip46Session(payload) {
return;
}
const normalized = sanitizeStoredNip46Session(payload);
const sanitizedInput =
payload && typeof payload === "object"
? {
version: payload.version,
clientPublicKey: payload.clientPublicKey,
remotePubkey: payload.remotePubkey,
relays: payload.relays,
encryption: payload.encryption,
permissions: payload.permissions,
metadata: payload.metadata,
userPubkey: payload.userPubkey,
lastConnectedAt: payload.lastConnectedAt,
}
: payload;
const normalized = sanitizeStoredNip46Session(sanitizedInput);
if (!normalized) {
try {
storage.removeItem(NIP46_SESSION_STORAGE_KEY);
+140 -38
View File
@@ -7,6 +7,10 @@ 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) {
@@ -62,6 +66,91 @@ function base64ToUint8Array(base64) {
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);
store.put(payload, 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() {
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);
store.delete(SESSION_ACTOR_STORAGE_KEY);
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) {
@@ -283,8 +372,6 @@ export function readStoredSessionActorEntry() {
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()
@@ -294,9 +381,33 @@ export function readStoredSessionActorEntry() {
? 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,
privateKey: "",
privateKeyEncrypted,
encryption,
createdAt,
@@ -317,16 +428,14 @@ export function readStoredSessionActorEntry() {
}
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))
typeof actor.privateKeyEncrypted !== "string" ||
!actor.privateKeyEncrypted ||
!actor.encryption ||
typeof actor.encryption !== "object"
) {
return;
}
@@ -340,43 +449,36 @@ export function persistSessionActor(actor) {
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 {
const normalizedEncryption = normalizeStoredEncryptionMetadata(actor.encryption);
if (!normalizedEncryption) {
return;
}
payload.privateKeyEncrypted = actor.privateKeyEncrypted;
payload.encryption = normalizedEncryption;
try {
localStorage.setItem(
SESSION_ACTOR_STORAGE_KEY,
JSON.stringify(payload),
);
} catch (error) {
devLogger.warn("[nostr] Failed to persist session actor:", error);
persistSessionActorToIndexedDb(payload);
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);
}
}
}
export function clearStoredSessionActor() {
if (typeof localStorage === "undefined") {
return;
}
clearStoredSessionActorIndexedDb();
try {
localStorage.removeItem(SESSION_ACTOR_STORAGE_KEY);
} catch (error) {
devLogger.warn("[nostr] Failed to clear stored session actor:", error);
if (typeof localStorage !== "undefined") {
try {
localStorage.removeItem(SESSION_ACTOR_STORAGE_KEY);
} catch (error) {
devLogger.warn("[nostr] Failed to clear stored session actor:", error);
}
}
}
+26
View File
@@ -200,6 +200,32 @@ export function getActiveSigner() {
return activeSigner;
}
export function logoutSigner(pubkey) {
const normalized = normalizePubkey(pubkey);
if (normalized) {
const entry = registeredSigners.get(normalized);
if (entry?.signer && typeof entry.signer.destroy === "function") {
try {
entry.signer.destroy();
} catch (error) {
// Ignore signer teardown failures.
}
}
registerSigner(normalized, null);
return;
}
if (activeSigner) {
const activePubkey = normalizePubkey(activeSigner.pubkey || activeSignerPubkey);
if (activePubkey) {
logoutSigner(activePubkey);
return;
}
}
updateActiveSigner(null, "");
}
export function resolveActiveSigner(pubkey) {
const normalized = normalizePubkey(pubkey);
if (normalized) {