mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
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:
1 parent
c539fef553
commit
8a9dcab4e1
2 files changed
+171
-30
No files matched your search
+73
-28
@@ -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
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user