@@ -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' ;
3030import { saveEvent } from './utilities/db' ;
3131import { api } from './utilities/browser-polyfill' ;
3232import { 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
101101const 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
103104let locked = true ; // start locked; determined on first isLocked check
104105let encryptionEnabled = false ; // cached encryption state for fast lookups
105106let autoLockTimeout = 15 * 60 * 1000 ; // 15 minutes default
106107let autoLockTimer = null ;
107- let nostrAccessWhileLocked = true ;
108+ let nostrAccessWhileLocked = false ;
108109
109110let 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+
212215function 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 */
240274async 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 ;
0 commit comments