Skip to content

Commit 8d2dde9

Browse files
committed
security: session key derivation, mutex, alarms, lock-clears-keys
C2: Default nostrAccessWhileLocked to false — locking now clears decrypted keys from memory by default. Users who want background signing can explicitly opt in. C3: Replace plaintext sessionPassword with an opaque CryptoKey derived via PBKDF2 at unlock time. The raw password is never held in memory beyond the call stack. New encryptWithKey() in crypto.js encrypts using the pre-derived key directly. C4: Wrap lockSession() and unlockSession() in a shared sessionMutex (async-mutex) so the auto-lock timer callback cannot interleave with an in-progress unlock and leave state inconsistent. H1: Use chrome.alarms API for auto-lock timer when available (Chrome MV3, Firefox). Alarms survive service-worker eviction, so the timer is no longer decorative on Chrome. Falls back to setTimeout on Safari background pages. Added "alarms" permission to all three manifests.
1 parent 7c6291f commit 8d2dde9

21 files changed

+528
-176
lines changed

distros/safari/api-keys/api-keys.build.js

Lines changed: 14 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

distros/safari/background.build.js

Lines changed: 112 additions & 49 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

distros/safari/background.js

Lines changed: 104 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import {
2626
getDecryptedPrivKey,
2727
isEncryptedBlob,
2828
} from './utilities/utils';
29-
import { encrypt as encryptBlob, decrypt as decryptBlob } from './utilities/crypto';
29+
import { encrypt as encryptBlob, decrypt as decryptBlob, encryptWithKey, deriveKey } from './utilities/crypto';
3030
import { saveEvent } from './utilities/db';
3131
import { api } from './utilities/browser-polyfill';
3232
import { initSync, scheduleSyncPush } from './utilities/sync-manager';
@@ -99,19 +99,20 @@ function isRateLimited(host) {
9999
// Decrypted keys are held in memory only while unlocked.
100100
// Map of profileIndex -> hex private key string
101101
const sessionKeys = new Map();
102-
let sessionPassword = null; // held in memory to re-encrypt new keys during session
102+
let sessionCryptoKey = null; // derived AES-256-GCM key (opaque CryptoKey, not raw password)
103+
let sessionKeySalt = null; // salt used to derive sessionCryptoKey
103104
let locked = true; // start locked; determined on first isLocked check
104105
let encryptionEnabled = false; // cached encryption state for fast lookups
105106
let autoLockTimeout = 15 * 60 * 1000; // 15 minutes default
106107
let autoLockTimer = null;
107-
let nostrAccessWhileLocked = true;
108+
let nostrAccessWhileLocked = false;
108109

109110
let blockCrossOriginFrames = true;
110111

111112
// Load persisted state on startup
112113
(async () => {
113114
log('[STARTUP] Reading persisted state...');
114-
const data = await storage.get({ autoLockMinutes: 15, isEncrypted: false, passwordHash: null, nostrAccessWhileLocked: true, blockCrossOriginFrames: true });
115+
const data = await storage.get({ autoLockMinutes: 15, isEncrypted: false, passwordHash: null, nostrAccessWhileLocked: false, blockCrossOriginFrames: true });
115116
log(`[STARTUP] isEncrypted=${data.isEncrypted}, passwordHash=${data.passwordHash ? 'EXISTS' : 'null'}, autoLockMinutes=${data.autoLockMinutes}`);
116117
autoLockTimeout = data.autoLockMinutes * 60 * 1000;
117118
// Defensive: if passwordHash exists but flag is stale, self-heal
@@ -209,62 +210,106 @@ function mergeSharedProfiles(localProfiles, sharedProfiles) {
209210
/**
210211
* Reset the auto-lock inactivity timer.
211212
*/
213+
const AUTO_LOCK_ALARM = 'nostrkey-auto-lock';
214+
212215
function resetAutoLock() {
213-
if (autoLockTimer) clearTimeout(autoLockTimer);
214-
if (!locked && autoLockTimeout > 0) {
215-
autoLockTimer = setTimeout(() => {
216-
lockSession();
217-
}, autoLockTimeout);
216+
// Clear any existing timer (setTimeout fallback)
217+
if (autoLockTimer) { clearTimeout(autoLockTimer); autoLockTimer = null; }
218+
219+
if (locked || autoLockTimeout <= 0) {
220+
// No timer needed — also clear any pending alarm
221+
api.alarms?.clear(AUTO_LOCK_ALARM).catch(() => {});
222+
return;
223+
}
224+
225+
// Prefer chrome.alarms (survives MV3 service-worker eviction)
226+
if (api.alarms) {
227+
api.alarms.create(AUTO_LOCK_ALARM, { delayInMinutes: autoLockTimeout / 60000 });
228+
} else {
229+
// Fallback for environments without alarms API (Safari background page)
230+
autoLockTimer = setTimeout(() => { lockSession(); }, autoLockTimeout);
218231
}
219232
}
220233

234+
// Listen for the alarm to fire
235+
if (api.alarms?.onAlarm) {
236+
api.alarms.onAlarm.addListener((alarm) => {
237+
if (alarm.name === AUTO_LOCK_ALARM) {
238+
lockSession();
239+
}
240+
});
241+
}
242+
243+
/**
244+
* Mutex that serializes lockSession / unlockSession so the auto-lock
245+
* timer callback cannot interleave with an in-progress unlock.
246+
*/
247+
const sessionMutex = new Mutex();
248+
221249
/**
222250
* Lock the session — clear all decrypted keys from memory.
223251
*/
224-
function lockSession() {
225-
if (!nostrAccessWhileLocked) {
226-
sessionKeys.clear();
227-
}
228-
sessionPassword = null;
229-
locked = true;
230-
if (autoLockTimer) {
231-
clearTimeout(autoLockTimer);
232-
autoLockTimer = null;
252+
async function lockSession() {
253+
const release = await sessionMutex.acquire();
254+
try {
255+
if (!nostrAccessWhileLocked) {
256+
sessionKeys.clear();
257+
}
258+
sessionCryptoKey = null;
259+
sessionKeySalt = null;
260+
locked = true;
261+
if (autoLockTimer) {
262+
clearTimeout(autoLockTimer);
263+
autoLockTimer = null;
264+
}
265+
log(`Session locked. Keys retained: ${nostrAccessWhileLocked && sessionKeys.size > 0}`);
266+
} finally {
267+
release();
233268
}
234-
log(`Session locked. Keys retained: ${nostrAccessWhileLocked && sessionKeys.size > 0}`);
235269
}
236270

237271
/**
238272
* Unlock the session — verify password and decrypt all keys into memory.
239273
*/
240274
async function unlockSession(password) {
241-
const valid = await checkPassword(password);
242-
if (!valid) return { success: false, error: 'Invalid password' };
243-
244-
const profiles = await getProfiles();
245-
let needsSave = false;
246-
for (let i = 0; i < profiles.length; i++) {
247-
if (profiles[i].type === 'bunker') continue;
248-
const hex = await getDecryptedPrivKey(profiles[i], password);
249-
sessionKeys.set(i, hex);
250-
// Cache pubKey if not already cached (for profiles encrypted before this fix)
251-
if (!profiles[i].pubKey && hex) {
252-
try {
253-
profiles[i].pubKey = getPublicKeySync(hex);
254-
needsSave = true;
255-
} catch (e) {
256-
console.error(`Failed to cache pubKey for profile ${i}:`, e);
275+
const release = await sessionMutex.acquire();
276+
try {
277+
const valid = await checkPassword(password);
278+
if (!valid) return { success: false, error: 'Invalid password' };
279+
280+
const profiles = await getProfiles();
281+
let needsSave = false;
282+
for (let i = 0; i < profiles.length; i++) {
283+
if (profiles[i].type === 'bunker') continue;
284+
const hex = await getDecryptedPrivKey(profiles[i], password);
285+
sessionKeys.set(i, hex);
286+
// Cache pubKey if not already cached (for profiles encrypted before this fix)
287+
if (!profiles[i].pubKey && hex) {
288+
try {
289+
profiles[i].pubKey = getPublicKeySync(hex);
290+
needsSave = true;
291+
} catch (e) {
292+
console.error(`Failed to cache pubKey for profile ${i}:`, e);
293+
}
257294
}
258295
}
296+
if (needsSave) {
297+
await storage.set({ profiles });
298+
}
299+
// Derive a session CryptoKey so we never hold the raw password in memory.
300+
// The salt is random per session; decrypt() still uses the password at
301+
// next unlock to re-derive from whatever salt was stored in each blob.
302+
const salt = crypto.getRandomValues(new Uint8Array(16));
303+
sessionCryptoKey = await deriveKey(password, salt);
304+
sessionKeySalt = salt;
305+
// password is now only on the call stack and will be GC'd
306+
locked = false;
307+
resetAutoLock();
308+
log('Session unlocked.');
309+
return { success: true };
310+
} finally {
311+
release();
259312
}
260-
if (needsSave) {
261-
await storage.set({ profiles });
262-
}
263-
sessionPassword = password;
264-
locked = false;
265-
resetAutoLock();
266-
log('Session unlocked.');
267-
return { success: true };
268313
}
269314

270315
/**
@@ -451,8 +496,7 @@ api.runtime.onMessage.addListener((message, _sender, sendResponse) => {
451496
reply(sendResponse, () => unlockSession(message.payload));
452497
return true;
453498
case 'lock':
454-
lockSession();
455-
sendResponse(true);
499+
lockSession().then(() => sendResponse(true));
456500
return true;
457501
case 'setPassword':
458502
(async () => {
@@ -496,7 +540,8 @@ api.runtime.onMessage.addListener((message, _sender, sendResponse) => {
496540
try {
497541
await removePasswordProtection(message.payload);
498542
sessionKeys.clear();
499-
sessionPassword = null;
543+
sessionCryptoKey = null;
544+
sessionKeySalt = null;
500545
locked = false;
501546
encryptionEnabled = false;
502547
// Broadcast password state change to all views
@@ -513,10 +558,11 @@ api.runtime.onMessage.addListener((message, _sender, sendResponse) => {
513558
// Clear all extension data and reset to fresh state
514559
await storage.clear();
515560
sessionKeys.clear();
516-
sessionPassword = null;
561+
sessionCryptoKey = null;
562+
sessionKeySalt = null;
517563
locked = false;
518564
encryptionEnabled = false;
519-
nostrAccessWhileLocked = true;
565+
nostrAccessWhileLocked = false;
520566
blockCrossOriginFrames = true;
521567
// Re-initialize with default profile
522568
await storage.set({
@@ -1039,7 +1085,7 @@ api.runtime.onMessage.addListener((message, _sender, sendResponse) => {
10391085
// --- Encrypted vault backup / restore ---
10401086
case 'backup.export':
10411087
reply(sendResponse, async () => {
1042-
if (!sessionPassword) {
1088+
if (!sessionCryptoKey) {
10431089
return { success: false, error: 'Extension must be unlocked to create a backup' };
10441090
}
10451091
const data = await storage.get({
@@ -1050,13 +1096,13 @@ api.runtime.onMessage.addListener((message, _sender, sendResponse) => {
10501096
passwordSalt: null,
10511097
apiKeyVault: null,
10521098
vaultDocs: null,
1053-
nostrAccessWhileLocked: true,
1099+
nostrAccessWhileLocked: false,
10541100
blockCrossOriginFrames: true,
10551101
autoLockMinutes: 15,
10561102
version: null,
10571103
});
10581104
const plaintext = JSON.stringify(data);
1059-
const encrypted = await encryptBlob(plaintext, sessionPassword);
1105+
const encrypted = await encryptWithKey(plaintext, sessionCryptoKey, sessionKeySalt);
10601106
const version = api.runtime.getManifest?.()?.version || 'unknown';
10611107
return {
10621108
success: true,
@@ -1094,7 +1140,10 @@ api.runtime.onMessage.addListener((message, _sender, sendResponse) => {
10941140
// Update in-memory state
10951141
encryptionEnabled = !!data.isEncrypted;
10961142
locked = false;
1097-
sessionPassword = password;
1143+
// Derive session key from password, then let password fall out of scope
1144+
const importSalt = crypto.getRandomValues(new Uint8Array(16));
1145+
sessionCryptoKey = await deriveKey(password, importSalt);
1146+
sessionKeySalt = importSalt;
10981147
nostrAccessWhileLocked = data.nostrAccessWhileLocked !== false;
10991148
blockCrossOriginFrames = data.blockCrossOriginFrames !== false;
11001149
if (typeof data.autoLockMinutes === 'number') {
@@ -1465,10 +1514,10 @@ async function savePrivateKey([index, privKey]) {
14651514
const pubKey = getPublicKeySync(hexKey);
14661515
profiles[index].pubKey = pubKey;
14671516

1468-
// If encryption is active, re-encrypt the new key using the session password
1517+
// If encryption is active, re-encrypt the new key using the session key
14691518
const encrypted = await isEncrypted();
1470-
if (encrypted && sessionPassword) {
1471-
profiles[index].privKey = await encryptBlob(hexKey, sessionPassword);
1519+
if (encrypted && sessionCryptoKey) {
1520+
profiles[index].privKey = await encryptWithKey(hexKey, sessionCryptoKey, sessionKeySalt);
14721521
sessionKeys.set(index, hexKey);
14731522
} else {
14741523
profiles[index].privKey = hexKey;

distros/safari/content.build.js

Lines changed: 14 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

distros/safari/event_history/event_history.build.js

Lines changed: 14 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

distros/safari/experimental/experimental.build.js

Lines changed: 14 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

distros/safari/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
"options_ui": {
3838
"page": "full_settings.html"
3939
},
40-
"permissions": ["storage", "clipboardWrite"],
40+
"permissions": ["storage", "clipboardWrite", "alarms"],
4141
"web_accessible_resources": [
4242
{
4343
"resources": ["nostr.build.js"],

distros/safari/nostr-keys/nostr-keys.build.js

Lines changed: 14 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

distros/safari/options.build.js

Lines changed: 14 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

distros/safari/permission/permission.build.js

Lines changed: 14 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)