fix(security): encrypt NIP-46 session keys in localStorage

- Implement AES-GCM encryption for NIP-46 `clientPrivateKey` and `secret` using `js/nostr/sessionActor.js` primitives.
- Update `writeStoredNip46Session` to accept an optional `passphrase` and encrypt sensitive fields before storage.
- Update `readStoredNip46Session` to support encrypted session retrieval.
- Add `decryptNip46Session` helper to unlock session keys with a user-provided passphrase.
- Update `NostrClient` methods `connectRemoteSigner` and `useStoredRemoteSigner` to support the new encryption flow.
- Ensure plain text keys are never persisted to `localStorage` (default safe behavior maintained).

Signed-off-by: Jules <[email protected]>

Co-authored-by: PR0M3TH3AN <[email protected]>
This commit is contained in:
google-labs-jules[bot]andPR0M3TH3AN committed 2026-02-07 23:15:48 +00:00
1 parent c539fef553
commit 8a9dcab4e1
2 files changed
+171 -30

No files matched your search

+73 -28
View File
@@ -165,6 +165,7 @@ import {
sanitizeRelayList,
readStoredNip46Session,
writeStoredNip46Session,
decryptNip46Session,
clearStoredNip46Session,
parseNip46ConnectionString,
generateNip46Secret,
@@ -1479,11 +1480,15 @@ export class NostrClient {
const remotePubkey = stored.remotePubkey || "";
const userPubkey = stored.userPubkey || "";
const canReconnect = Boolean(stored.clientPrivateKey && stored.secret);
const hasEncryptedSession =
typeof stored.encryptedSecrets === "string" && !!stored.encryptedSecrets;
const canReconnect =
Boolean(stored.clientPrivateKey && stored.secret) || hasEncryptedSession;
return {
hasSession: true,
canReconnect,
hasEncryptedSession,
remotePubkey,
remoteNpub: encodeHexToNpub(remotePubkey),
clientPublicKey: stored.clientPublicKey || "",
@@ -2234,6 +2239,7 @@ export class NostrClient {
onAuthUrl,
onStatus,
handshakeTimeoutMs,
passphrase,
} = {}) {
const parsed = parseNip46ConnectionString(connectionString);
if (!parsed) {
@@ -2552,19 +2558,26 @@ export class NostrClient {
encryption: client.encryptionAlgorithm || handshakeAlgorithm || "",
permissions: permissions || null,
});
writeStoredNip46Session({
version: 1,
clientPublicKey,
remotePubkey,
relays,
encryption: client.encryptionAlgorithm || handshakeAlgorithm || "",
permissions,
metadata,
userPubkey,
lastConnectedAt: Date.now(),
});
await writeStoredNip46Session(
{
version: 1,
clientPublicKey,
remotePubkey,
relays,
encryption: client.encryptionAlgorithm || handshakeAlgorithm || "",
permissions,
metadata,
userPubkey,
lastConnectedAt: Date.now(),
clientPrivateKey,
secret,
},
passphrase,
);
} else {
devLogger.debug("[nostr] Clearing stored remote signer session per request");
devLogger.debug(
"[nostr] Clearing stored remote signer session per request",
);
clearStoredNip46Session();
}
@@ -2637,15 +2650,40 @@ export class NostrClient {
options && typeof options === "object" ? options : {};
const silent = normalizedOptions.silent === true;
const forgetOnError = normalizedOptions.forgetOnError === true;
const validator = typeof normalizedOptions.validator === "function" ? normalizedOptions.validator : null;
const passphrase =
typeof normalizedOptions.passphrase === "string"
? normalizedOptions.passphrase
: null;
const validator =
typeof normalizedOptions.validator === "function"
? normalizedOptions.validator
: null;
const stored = readStoredNip46Session();
let stored = readStoredNip46Session();
if (!stored) {
const error = new Error("No remote signer session is stored on this device.");
const error = new Error(
"No remote signer session is stored on this device.",
);
error.code = "no-stored-session";
throw error;
}
if (stored.encryptedSecrets) {
if (!passphrase) {
const error = new Error("Passphrase required to unlock session.");
error.code = "passphrase-required";
throw error;
}
try {
stored = await decryptNip46Session(stored, passphrase);
} catch (decryptError) {
const error = new Error("Failed to decrypt stored session.");
error.code = "decrypt-failed";
error.cause = decryptError;
throw error;
}
}
const relays = resolveNip46Relays(stored.relays, this.relays);
devLogger.debug("[nostr] Reconnecting to stored remote signer", {
@@ -2664,7 +2702,9 @@ export class NostrClient {
});
if (!stored.clientPrivateKey || !stored.secret) {
const error = new Error("Stored remote signer credentials are unavailable.");
const error = new Error(
"Stored remote signer credentials are unavailable.",
);
error.code = "stored-session-missing-credentials";
throw error;
}
@@ -2708,17 +2748,22 @@ export class NostrClient {
client.metadata = stored.metadata;
const signer = await this.installNip46Client(client, { userPubkey });
writeStoredNip46Session({
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(),
});
await writeStoredNip46Session(
{
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(),
clientPrivateKey: stored.clientPrivateKey,
secret: stored.secret,
},
passphrase,
);
devLogger.debug("[nostr] Stored remote signer session refreshed", {
remotePubkey: summarizeHexForLog(stored.remotePubkey || ""),
+98 -2
View File
@@ -14,6 +14,11 @@
*/
import { isDevMode } from "../config.js";
import {
encryptSessionPrivateKey,
decryptSessionPrivateKey,
normalizeStoredEncryptionMetadata,
} from "./sessionActor.js";
import {
DEFAULT_RELAY_URLS,
ensureNostrTools,
@@ -223,6 +228,11 @@ export function sanitizeStoredNip46Session(candidate) {
lastConnectedAt: Number.isFinite(candidate.lastConnectedAt)
? candidate.lastConnectedAt
: Date.now(),
encryptedSecrets:
typeof candidate.encryptedSecrets === "string"
? candidate.encryptedSecrets.trim()
: "",
keyEncryption: normalizeStoredEncryptionMetadata(candidate.keyEncryption),
};
}
@@ -251,7 +261,7 @@ export function readStoredNip46Session() {
(typeof parsed?.clientPrivateKey === "string" ||
typeof parsed?.secret === "string")
) {
writeStoredNip46Session(sanitized);
writeStoredNip46SessionSync(sanitized);
}
return sanitized;
} catch (error) {
@@ -267,7 +277,7 @@ export function readStoredNip46Session() {
}
}
export function writeStoredNip46Session(payload) {
export function writeStoredNip46SessionSync(payload) {
const storage = getNip46Storage();
if (!storage) {
return;
@@ -285,6 +295,8 @@ export function writeStoredNip46Session(payload) {
metadata: payload.metadata,
userPubkey: payload.userPubkey,
lastConnectedAt: payload.lastConnectedAt,
encryptedSecrets: payload.encryptedSecrets,
keyEncryption: payload.keyEncryption,
}
: payload;
const normalized = sanitizeStoredNip46Session(sanitizedInput);
@@ -304,6 +316,90 @@ export function writeStoredNip46Session(payload) {
}
}
export async function writeStoredNip46Session(payload, passphrase) {
if (!payload || typeof payload !== "object") {
return;
}
const update = { ...payload };
if (
passphrase &&
typeof passphrase === "string" &&
passphrase.trim() &&
typeof update.clientPrivateKey === "string" &&
update.clientPrivateKey &&
typeof update.secret === "string" &&
update.secret
) {
const secrets = JSON.stringify({
clientPrivateKey: update.clientPrivateKey,
secret: update.secret,
});
try {
const encrypted = await encryptSessionPrivateKey(
secrets,
passphrase.trim(),
);
update.encryptedSecrets = encrypted.ciphertext;
update.keyEncryption = {
salt: encrypted.salt,
iv: encrypted.iv,
iterations: encrypted.iterations,
hash: encrypted.hash,
algorithm: encrypted.algorithm,
version: encrypted.version,
};
} catch (error) {
devLogger.warn("[nostr] Failed to encrypt remote signer session:", error);
// Fall through to write sanitized (non-sensitive) data only
}
}
// Ensure plain text keys are never persisted
delete update.clientPrivateKey;
delete update.secret;
writeStoredNip46SessionSync(update);
}
export async function decryptNip46Session(session, passphrase) {
if (!session || typeof session !== "object") {
return null;
}
if (
!session.encryptedSecrets ||
typeof session.encryptedSecrets !== "string"
) {
return { ...session };
}
if (!passphrase || typeof passphrase !== "string" || !passphrase.trim()) {
throw new Error("Passphrase required to decrypt session.");
}
const payload = {
privateKeyEncrypted: session.encryptedSecrets,
encryption: session.keyEncryption,
};
const decryptedJson = await decryptSessionPrivateKey(payload, passphrase);
let secrets = null;
try {
secrets = JSON.parse(decryptedJson);
} catch (error) {
throw new Error("Decrypted session secrets are malformed.");
}
return {
...session,
clientPrivateKey: secrets.clientPrivateKey,
secret: secrets.secret,
};
}
export function clearStoredNip46Session() {
const storage = getNip46Storage();
if (!storage) {