mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-31 01:42:27 +00:00
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:
4 files changed
+217
-74
No files matched your search
+26
-24
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user