background-common.ts raw
1 /* eslint-disable @typescript-eslint/no-explicit-any */
2 import {
3 BrowserSessionData,
4 BrowserSyncData,
5 BrowserSyncFlow,
6 CryptoHelper,
7 SignerMetaData,
8 Identity_DECRYPTED,
9 Identity_ENCRYPTED,
10 Nip07Method,
11 Nip07MethodPolicy,
12 NostrHelper,
13 Permission_DECRYPTED,
14 Permission_ENCRYPTED,
15 Relay_DECRYPTED,
16 Relay_ENCRYPTED,
17 NwcConnection_DECRYPTED,
18 NwcConnection_ENCRYPTED,
19 CashuMint_DECRYPTED,
20 CashuMint_ENCRYPTED,
21 deriveKeyArgon2,
22 ExtensionMethod,
23 WeblnMethod,
24 } from '@common';
25 import { FirefoxMetaHandler } from './app/common/data/firefox-meta-handler';
26 import { Event, EventTemplate, finalizeEvent, nip04, nip44 } from 'nostr-tools';
27 import { Buffer } from 'buffer';
28 import browser from 'webextension-polyfill';
29
30 // Unlock request/response message types
31 export interface UnlockRequestMessage {
32 type: 'unlock-request';
33 id: string;
34 password: string;
35 }
36
37 export interface UnlockResponseMessage {
38 type: 'unlock-response';
39 id: string;
40 success: boolean;
41 error?: string;
42 }
43
44 export const debug = function (_message: any) {
45 // Enable for debugging: console.log(`[Smesh Signer]: ${JSON.stringify(_message)}`);
46 };
47
48 export type PromptResponse =
49 | 'reject'
50 | 'reject-once'
51 | 'reject-all' // P2: Reject all requests of this type from this host
52 | 'approve'
53 | 'approve-once'
54 | 'approve-all'; // P2: Approve all requests of this type from this host
55
56 export interface PromptResponseMessage {
57 id: string;
58 response: PromptResponse;
59 }
60
61 export interface BackgroundRequestMessage {
62 method: ExtensionMethod;
63 params: any;
64 host: string;
65 }
66
67 export const getBrowserSessionData = async function (): Promise<
68 BrowserSessionData | undefined
69 > {
70 const browserSessionData = await browser.storage.session.get(null);
71 // Check for required vault session keys, not just any key existing.
72 // Stray keys in session storage (e.g. profile cache) must not cause
73 // the vault to appear unlocked.
74 if (
75 !browserSessionData ||
76 !browserSessionData['iv'] ||
77 !browserSessionData['identities']
78 ) {
79 return undefined;
80 }
81
82 return browserSessionData as unknown as BrowserSessionData;
83 };
84
85 export const getSignerMetaData = async function (): Promise<SignerMetaData> {
86 const signerMetaHandler = new FirefoxMetaHandler();
87 return (await signerMetaHandler.loadFullData()) as SignerMetaData;
88 };
89
90 /**
91 * Check if reckless mode should auto-approve the request.
92 * Returns true if should auto-approve, false if should use normal permission flow.
93 *
94 * Logic:
95 * - If reckless mode is OFF → return false (use normal flow)
96 * - If reckless mode is ON and whitelist is empty → return true (approve all)
97 * - If reckless mode is ON and whitelist has entries → return true only if host is in whitelist
98 */
99 export const shouldRecklessModeApprove = async function (
100 host: string
101 ): Promise<boolean> {
102 const signerMetaData = await getSignerMetaData();
103 debug(`shouldRecklessModeApprove: recklessMode=${signerMetaData.recklessMode}, host=${host}`);
104 debug(`Full signerMetaData: ${JSON.stringify(signerMetaData)}`);
105
106 if (!signerMetaData.recklessMode) {
107 return false;
108 }
109
110 const whitelistedHosts = signerMetaData.whitelistedHosts ?? [];
111
112 if (whitelistedHosts.length === 0) {
113 // Reckless mode ON, no whitelist → approve all
114 return true;
115 }
116
117 // Reckless mode ON, whitelist has entries → only approve if host is whitelisted
118 return whitelistedHosts.includes(host);
119 };
120
121 export const getBrowserSyncData = async function (): Promise<
122 BrowserSyncData | undefined
123 > {
124 const signerMetaHandler = new FirefoxMetaHandler();
125 const signerMetaData =
126 (await signerMetaHandler.loadFullData()) as SignerMetaData;
127
128 let browserSyncData: BrowserSyncData | undefined;
129
130 if (signerMetaData.syncFlow === BrowserSyncFlow.NO_SYNC) {
131 browserSyncData = (await browser.storage.local.get(null)) as unknown as BrowserSyncData;
132 } else if (signerMetaData.syncFlow === BrowserSyncFlow.BROWSER_SYNC) {
133 browserSyncData = (await browser.storage.sync.get(null)) as unknown as BrowserSyncData;
134 }
135
136 return browserSyncData;
137 };
138
139 export const savePermissionsToBrowserSyncStorage = async function (
140 permissions: Permission_ENCRYPTED[]
141 ): Promise<void> {
142 const signerMetaHandler = new FirefoxMetaHandler();
143 const signerMetaData =
144 (await signerMetaHandler.loadFullData()) as SignerMetaData;
145
146 if (signerMetaData.syncFlow === BrowserSyncFlow.NO_SYNC) {
147 await browser.storage.local.set({ permissions });
148 } else if (signerMetaData.syncFlow === BrowserSyncFlow.BROWSER_SYNC) {
149 await browser.storage.sync.set({ permissions });
150 }
151 };
152
153 export const checkPermissions = function (
154 browserSessionData: BrowserSessionData,
155 identity: Identity_DECRYPTED,
156 host: string,
157 method: Nip07Method,
158 params: any
159 ): boolean | undefined {
160 // MLS methods — check for 'mls.*' wildcard permission first.
161 // Must be before the generic filter which would match on the exact method
162 // name (e.g. 'mls.sendDM') and return undefined since perms are stored as 'mls.*'.
163 if ((method as string).startsWith('mls.')) {
164 const mlsPerms = browserSessionData.permissions.filter(
165 (x) => x.identityId === identity.id && x.host === host && x.method === ('mls.*' as Nip07Method)
166 );
167 if (mlsPerms.length === 0) return undefined;
168 return mlsPerms.every((x) => x.methodPolicy === 'allow');
169 }
170
171 const permissions = browserSessionData.permissions.filter(
172 (x) =>
173 x.identityId === identity.id && x.host === host && x.method === method
174 );
175
176 if (permissions.length === 0) {
177 return undefined;
178 }
179
180 if (method === 'getPublicKey') {
181 return permissions.every((x) => x.methodPolicy === 'allow');
182 }
183
184 if (method === 'getRelays') {
185 return permissions.every((x) => x.methodPolicy === 'allow');
186 }
187
188 if (method === 'signEvent') {
189 const eventTemplate = params as EventTemplate;
190 if (
191 permissions.find(
192 (x) => x.methodPolicy === 'allow' && typeof x.kind === 'undefined'
193 )
194 ) {
195 return true;
196 }
197
198 if (
199 permissions.some(
200 (x) => x.methodPolicy === 'allow' && x.kind === eventTemplate.kind
201 )
202 ) {
203 return true;
204 }
205
206 if (
207 permissions.some(
208 (x) => x.methodPolicy === 'deny' && x.kind === eventTemplate.kind
209 )
210 ) {
211 return false;
212 }
213
214 return undefined;
215 }
216
217 if (method === 'nip04.encrypt') {
218 return permissions.every((x) => x.methodPolicy === 'allow');
219 }
220
221 if (method === 'nip44.encrypt') {
222 return permissions.every((x) => x.methodPolicy === 'allow');
223 }
224
225 if (method === 'nip04.decrypt') {
226 return permissions.every((x) => x.methodPolicy === 'allow');
227 }
228
229 if (method === 'nip44.decrypt') {
230 return permissions.every((x) => x.methodPolicy === 'allow');
231 }
232
233 return undefined;
234 };
235
236 /**
237 * Check if a method is a WebLN method
238 */
239 export const isWeblnMethod = function (method: ExtensionMethod): method is WeblnMethod {
240 return method.startsWith('webln.');
241 };
242
243 /**
244 * Check WebLN permissions for a host.
245 * Note: WebLN permissions are NOT tied to identities since the wallet is global.
246 * For sendPayment, always returns undefined (require user prompt for security).
247 */
248 export const checkWeblnPermissions = function (
249 browserSessionData: BrowserSessionData,
250 host: string,
251 method: WeblnMethod
252 ): boolean | undefined {
253 // sendPayment ALWAYS requires user approval (security-critical, irreversible)
254 if (method === 'webln.sendPayment') {
255 return undefined;
256 }
257
258 // keysend also requires approval
259 if (method === 'webln.keysend') {
260 return undefined;
261 }
262
263 // For other WebLN methods, check stored permissions
264 // WebLN permissions use 'webln' as the identityId
265 const permissions = browserSessionData.permissions.filter(
266 (x) => x.identityId === 'webln' && x.host === host && x.method === method
267 );
268
269 if (permissions.length === 0) {
270 return undefined;
271 }
272
273 return permissions.every((x) => x.methodPolicy === 'allow');
274 };
275
276 export const storePermission = async function (
277 browserSessionData: BrowserSessionData,
278 identity: Identity_DECRYPTED | null,
279 host: string,
280 method: ExtensionMethod,
281 methodPolicy: Nip07MethodPolicy,
282 kind?: number
283 ) {
284 // Re-read current session and sync data to avoid race conditions.
285 // Multiple concurrent processNip07Request calls may store permissions
286 // simultaneously — using the stale browserSessionData from request start
287 // would overwrite permissions stored by other concurrent requests.
288 const freshSession = await getBrowserSessionData();
289 const freshPermissions = freshSession?.permissions ?? browserSessionData.permissions;
290
291 const browserSyncData = await getBrowserSyncData();
292 if (!browserSyncData) {
293 throw new Error(`Could not retrieve sync data`);
294 }
295
296 // For WebLN methods, use 'webln' as identityId since wallet is global
297 const identityId = identity?.id ?? 'webln';
298
299 const permission: Permission_DECRYPTED = {
300 id: crypto.randomUUID(),
301 identityId,
302 host,
303 method: method as Nip07Method, // Cast for storage compatibility
304 methodPolicy,
305 kind,
306 };
307
308 // Store session data (using fresh permissions to avoid overwriting concurrent writes)
309 await browser.storage.session.set({
310 permissions: [...freshPermissions, permission],
311 });
312
313 // Encrypt permission to store in sync storage (depending on sync flow).
314 const encryptedPermission = await encryptPermission(
315 permission,
316 browserSessionData
317 );
318
319 await savePermissionsToBrowserSyncStorage([
320 ...browserSyncData.permissions,
321 encryptedPermission,
322 ]);
323 };
324
325 export const getPosition = async function (width: number, height: number) {
326 let left = 0;
327 let top = 0;
328
329 try {
330 const lastFocused = await browser.windows.getLastFocused();
331
332 if (
333 lastFocused &&
334 lastFocused.top !== undefined &&
335 lastFocused.left !== undefined &&
336 lastFocused.width !== undefined &&
337 lastFocused.height !== undefined
338 ) {
339 // Position window in the center of the lastFocused window
340 top = Math.round(lastFocused.top + (lastFocused.height - height) / 2);
341 left = Math.round(lastFocused.left + (lastFocused.width - width) / 2);
342 } else {
343 console.error('Last focused window properties are undefined.');
344 }
345 } catch (error) {
346 console.error('Error getting window position:', error);
347 }
348
349 return {
350 top,
351 left,
352 };
353 };
354
355 export const signEvent = function (
356 eventTemplate: EventTemplate,
357 privkey: string
358 ): Event {
359 return finalizeEvent(eventTemplate, NostrHelper.hex2bytes(privkey));
360 };
361
362 export const nip04Encrypt = async function (
363 privkey: string,
364 peerPubkey: string,
365 plaintext: string
366 ): Promise<string> {
367 return await nip04.encrypt(
368 NostrHelper.hex2bytes(privkey),
369 peerPubkey,
370 plaintext
371 );
372 };
373
374 export const nip44Encrypt = async function (
375 privkey: string,
376 peerPubkey: string,
377 plaintext: string
378 ): Promise<string> {
379 const key = nip44.v2.utils.getConversationKey(
380 NostrHelper.hex2bytes(privkey),
381 peerPubkey
382 );
383 return nip44.v2.encrypt(plaintext, key);
384 };
385
386 export const nip04Decrypt = async function (
387 privkey: string,
388 peerPubkey: string,
389 ciphertext: string
390 ): Promise<string> {
391 return await nip04.decrypt(
392 NostrHelper.hex2bytes(privkey),
393 peerPubkey,
394 ciphertext
395 );
396 };
397
398 export const nip44Decrypt = async function (
399 privkey: string,
400 peerPubkey: string,
401 ciphertext: string
402 ): Promise<string> {
403 const key = nip44.v2.utils.getConversationKey(
404 NostrHelper.hex2bytes(privkey),
405 peerPubkey
406 );
407
408 return nip44.v2.decrypt(ciphertext, key);
409 };
410
411 const encryptPermission = async function (
412 permission: Permission_DECRYPTED,
413 sessionData: BrowserSessionData
414 ): Promise<Permission_ENCRYPTED> {
415 const encryptedPermission: Permission_ENCRYPTED = {
416 id: await encrypt(permission.id, sessionData),
417 identityId: await encrypt(permission.identityId, sessionData),
418 host: await encrypt(permission.host, sessionData),
419 method: await encrypt(permission.method, sessionData),
420 methodPolicy: await encrypt(permission.methodPolicy, sessionData),
421 };
422
423 if (typeof permission.kind !== 'undefined') {
424 encryptedPermission.kind = await encrypt(
425 permission.kind.toString(),
426 sessionData
427 );
428 }
429
430 return encryptedPermission;
431 };
432
433 const encrypt = async function (
434 value: string,
435 sessionData: BrowserSessionData
436 ): Promise<string> {
437 // v2: Use pre-derived key with AES-GCM directly
438 if (sessionData.vaultKey) {
439 const keyBytes = Buffer.from(sessionData.vaultKey, 'base64');
440 const iv = Buffer.from(sessionData.iv, 'base64');
441
442 const key = await crypto.subtle.importKey(
443 'raw',
444 keyBytes,
445 { name: 'AES-GCM' },
446 false,
447 ['encrypt']
448 );
449
450 const cipherText = await crypto.subtle.encrypt(
451 { name: 'AES-GCM', iv },
452 key,
453 new TextEncoder().encode(value)
454 );
455
456 return Buffer.from(cipherText).toString('base64');
457 }
458
459 // v1: Use password with PBKDF2
460 return await CryptoHelper.encrypt(value, sessionData.iv, sessionData.vaultPassword!);
461 };
462
463 // ==========================================
464 // Unlock Vault Logic (for background script)
465 // ==========================================
466
467 /**
468 * Decrypt a value using AES-GCM with pre-derived key (v2)
469 */
470 async function decryptV2(
471 encryptedBase64: string,
472 ivBase64: string,
473 keyBase64: string
474 ): Promise<string> {
475 const keyBytes = Buffer.from(keyBase64, 'base64');
476 const iv = Buffer.from(ivBase64, 'base64');
477 const cipherText = Buffer.from(encryptedBase64, 'base64');
478
479 const key = await crypto.subtle.importKey(
480 'raw',
481 keyBytes,
482 { name: 'AES-GCM' },
483 false,
484 ['decrypt']
485 );
486
487 const decrypted = await crypto.subtle.decrypt(
488 { name: 'AES-GCM', iv },
489 key,
490 cipherText
491 );
492
493 return new TextDecoder().decode(decrypted);
494 }
495
496 /**
497 * Decrypt a value using PBKDF2 (v1)
498 */
499 async function decryptV1(
500 encryptedBase64: string,
501 ivBase64: string,
502 password: string
503 ): Promise<string> {
504 return CryptoHelper.decrypt(encryptedBase64, ivBase64, password);
505 }
506
507 /**
508 * Generic decrypt function that handles both v1 and v2
509 */
510 async function decryptValue(
511 encrypted: string,
512 iv: string,
513 keyOrPassword: string,
514 isV2: boolean
515 ): Promise<string> {
516 if (isV2) {
517 return decryptV2(encrypted, iv, keyOrPassword);
518 }
519 return decryptV1(encrypted, iv, keyOrPassword);
520 }
521
522 /**
523 * Parse decrypted value to the desired type
524 */
525 function parseValue(value: string, type: 'string' | 'number' | 'boolean'): any {
526 switch (type) {
527 case 'number':
528 return parseInt(value);
529 case 'boolean':
530 return value === 'true';
531 default:
532 return value;
533 }
534 }
535
536 /**
537 * Decrypt an identity
538 */
539 async function decryptIdentity(
540 identity: Identity_ENCRYPTED,
541 iv: string,
542 keyOrPassword: string,
543 isV2: boolean
544 ): Promise<Identity_DECRYPTED> {
545 return {
546 id: await decryptValue(identity.id, iv, keyOrPassword, isV2),
547 nick: await decryptValue(identity.nick, iv, keyOrPassword, isV2),
548 createdAt: await decryptValue(identity.createdAt, iv, keyOrPassword, isV2),
549 privkey: await decryptValue(identity.privkey, iv, keyOrPassword, isV2),
550 };
551 }
552
553 /**
554 * Decrypt a permission
555 */
556 async function decryptPermission(
557 permission: Permission_ENCRYPTED,
558 iv: string,
559 keyOrPassword: string,
560 isV2: boolean
561 ): Promise<Permission_DECRYPTED> {
562 const decrypted: Permission_DECRYPTED = {
563 id: await decryptValue(permission.id, iv, keyOrPassword, isV2),
564 identityId: await decryptValue(permission.identityId, iv, keyOrPassword, isV2),
565 host: await decryptValue(permission.host, iv, keyOrPassword, isV2),
566 method: await decryptValue(permission.method, iv, keyOrPassword, isV2) as Nip07Method,
567 methodPolicy: await decryptValue(permission.methodPolicy, iv, keyOrPassword, isV2) as Nip07MethodPolicy,
568 };
569 if (permission.kind) {
570 decrypted.kind = parseValue(await decryptValue(permission.kind, iv, keyOrPassword, isV2), 'number');
571 }
572 return decrypted;
573 }
574
575 /**
576 * Decrypt a relay
577 */
578 async function decryptRelay(
579 relay: Relay_ENCRYPTED,
580 iv: string,
581 keyOrPassword: string,
582 isV2: boolean
583 ): Promise<Relay_DECRYPTED> {
584 return {
585 id: await decryptValue(relay.id, iv, keyOrPassword, isV2),
586 identityId: await decryptValue(relay.identityId, iv, keyOrPassword, isV2),
587 url: await decryptValue(relay.url, iv, keyOrPassword, isV2),
588 read: parseValue(await decryptValue(relay.read, iv, keyOrPassword, isV2), 'boolean'),
589 write: parseValue(await decryptValue(relay.write, iv, keyOrPassword, isV2), 'boolean'),
590 };
591 }
592
593 /**
594 * Decrypt an NWC connection
595 */
596 async function decryptNwcConnection(
597 nwc: NwcConnection_ENCRYPTED,
598 iv: string,
599 keyOrPassword: string,
600 isV2: boolean
601 ): Promise<NwcConnection_DECRYPTED> {
602 const decrypted: NwcConnection_DECRYPTED = {
603 id: await decryptValue(nwc.id, iv, keyOrPassword, isV2),
604 name: await decryptValue(nwc.name, iv, keyOrPassword, isV2),
605 connectionUrl: await decryptValue(nwc.connectionUrl, iv, keyOrPassword, isV2),
606 walletPubkey: await decryptValue(nwc.walletPubkey, iv, keyOrPassword, isV2),
607 relayUrl: await decryptValue(nwc.relayUrl, iv, keyOrPassword, isV2),
608 secret: await decryptValue(nwc.secret, iv, keyOrPassword, isV2),
609 createdAt: await decryptValue(nwc.createdAt, iv, keyOrPassword, isV2),
610 };
611 if (nwc.lud16) {
612 decrypted.lud16 = await decryptValue(nwc.lud16, iv, keyOrPassword, isV2);
613 }
614 if (nwc.cachedBalance) {
615 decrypted.cachedBalance = parseValue(await decryptValue(nwc.cachedBalance, iv, keyOrPassword, isV2), 'number');
616 }
617 if (nwc.cachedBalanceAt) {
618 decrypted.cachedBalanceAt = await decryptValue(nwc.cachedBalanceAt, iv, keyOrPassword, isV2);
619 }
620 return decrypted;
621 }
622
623 /**
624 * Decrypt a Cashu mint
625 */
626 async function decryptCashuMint(
627 mint: CashuMint_ENCRYPTED,
628 iv: string,
629 keyOrPassword: string,
630 isV2: boolean
631 ): Promise<CashuMint_DECRYPTED> {
632 const proofsJson = await decryptValue(mint.proofs, iv, keyOrPassword, isV2);
633 const decrypted: CashuMint_DECRYPTED = {
634 id: await decryptValue(mint.id, iv, keyOrPassword, isV2),
635 name: await decryptValue(mint.name, iv, keyOrPassword, isV2),
636 mintUrl: await decryptValue(mint.mintUrl, iv, keyOrPassword, isV2),
637 unit: await decryptValue(mint.unit, iv, keyOrPassword, isV2),
638 createdAt: await decryptValue(mint.createdAt, iv, keyOrPassword, isV2),
639 proofs: JSON.parse(proofsJson),
640 };
641 if (mint.cachedBalance) {
642 decrypted.cachedBalance = parseValue(await decryptValue(mint.cachedBalance, iv, keyOrPassword, isV2), 'number');
643 }
644 if (mint.cachedBalanceAt) {
645 decrypted.cachedBalanceAt = await decryptValue(mint.cachedBalanceAt, iv, keyOrPassword, isV2);
646 }
647 return decrypted;
648 }
649
650 /**
651 * Handle an unlock request from the unlock popup
652 */
653 export async function handleUnlockRequest(
654 password: string
655 ): Promise<{ success: boolean; error?: string }> {
656 try {
657 debug('handleUnlockRequest: Starting unlock...');
658
659 // Check if already unlocked
660 const existingSession = await getBrowserSessionData();
661 if (existingSession) {
662 debug('handleUnlockRequest: Already unlocked');
663 return { success: true };
664 }
665
666 // Get sync data
667 const browserSyncData = await getBrowserSyncData();
668 if (!browserSyncData) {
669 return { success: false, error: 'No vault data found' };
670 }
671
672 // Verify password
673 const passwordHash = await CryptoHelper.hash(password);
674 if (passwordHash !== browserSyncData.vaultHash) {
675 return { success: false, error: 'Invalid password' };
676 }
677 debug('handleUnlockRequest: Password verified');
678
679 // Detect vault version
680 const isV2 = !!browserSyncData.salt;
681 debug(`handleUnlockRequest: Vault version: ${isV2 ? 'v2' : 'v1'}`);
682
683 let keyOrPassword: string;
684 let vaultKey: string | undefined;
685 let vaultPassword: string | undefined;
686
687 if (isV2) {
688 // v2: Derive key with Argon2id (~3 seconds)
689 debug('handleUnlockRequest: Deriving Argon2id key...');
690 const saltBytes = Buffer.from(browserSyncData.salt!, 'base64');
691 const keyBytes = await deriveKeyArgon2(password, saltBytes);
692 vaultKey = Buffer.from(keyBytes).toString('base64');
693 keyOrPassword = vaultKey;
694 debug('handleUnlockRequest: Key derived');
695 } else {
696 // v1: Use password directly
697 vaultPassword = password;
698 keyOrPassword = password;
699 }
700
701 // Decrypt identities
702 debug('handleUnlockRequest: Decrypting identities...');
703 const decryptedIdentities: Identity_DECRYPTED[] = [];
704 for (const identity of browserSyncData.identities) {
705 const decrypted = await decryptIdentity(identity, browserSyncData.iv, keyOrPassword, isV2);
706 decryptedIdentities.push(decrypted);
707 }
708 debug(`handleUnlockRequest: Decrypted ${decryptedIdentities.length} identities`);
709
710 // Decrypt permissions
711 debug('handleUnlockRequest: Decrypting permissions...');
712 const decryptedPermissions: Permission_DECRYPTED[] = [];
713 for (const permission of browserSyncData.permissions) {
714 try {
715 const decrypted = await decryptPermission(permission, browserSyncData.iv, keyOrPassword, isV2);
716 decryptedPermissions.push(decrypted);
717 } catch (e) {
718 debug(`handleUnlockRequest: Skipping corrupted permission: ${e}`);
719 }
720 }
721 debug(`handleUnlockRequest: Decrypted ${decryptedPermissions.length} permissions`);
722
723 // Decrypt relays
724 debug('handleUnlockRequest: Decrypting relays...');
725 const decryptedRelays: Relay_DECRYPTED[] = [];
726 for (const relay of browserSyncData.relays) {
727 const decrypted = await decryptRelay(relay, browserSyncData.iv, keyOrPassword, isV2);
728 decryptedRelays.push(decrypted);
729 }
730 debug(`handleUnlockRequest: Decrypted ${decryptedRelays.length} relays`);
731
732 // Decrypt NWC connections
733 debug('handleUnlockRequest: Decrypting NWC connections...');
734 const decryptedNwcConnections: NwcConnection_DECRYPTED[] = [];
735 for (const nwc of browserSyncData.nwcConnections ?? []) {
736 const decrypted = await decryptNwcConnection(nwc, browserSyncData.iv, keyOrPassword, isV2);
737 decryptedNwcConnections.push(decrypted);
738 }
739 debug(`handleUnlockRequest: Decrypted ${decryptedNwcConnections.length} NWC connections`);
740
741 // Decrypt Cashu mints
742 debug('handleUnlockRequest: Decrypting Cashu mints...');
743 const decryptedCashuMints: CashuMint_DECRYPTED[] = [];
744 for (const mint of browserSyncData.cashuMints ?? []) {
745 const decrypted = await decryptCashuMint(mint, browserSyncData.iv, keyOrPassword, isV2);
746 decryptedCashuMints.push(decrypted);
747 }
748 debug(`handleUnlockRequest: Decrypted ${decryptedCashuMints.length} Cashu mints`);
749
750 // Decrypt selectedIdentityId
751 let decryptedSelectedIdentityId: string | null = null;
752 if (browserSyncData.selectedIdentityId !== null) {
753 decryptedSelectedIdentityId = await decryptValue(
754 browserSyncData.selectedIdentityId,
755 browserSyncData.iv,
756 keyOrPassword,
757 isV2
758 );
759 }
760 debug(`handleUnlockRequest: selectedIdentityId: ${decryptedSelectedIdentityId}`);
761
762 // Build session data
763 const browserSessionData: BrowserSessionData = {
764 vaultPassword: isV2 ? undefined : vaultPassword,
765 vaultKey: isV2 ? vaultKey : undefined,
766 iv: browserSyncData.iv,
767 salt: browserSyncData.salt,
768 permissions: decryptedPermissions,
769 identities: decryptedIdentities,
770 selectedIdentityId: decryptedSelectedIdentityId,
771 relays: decryptedRelays,
772 nwcConnections: decryptedNwcConnections,
773 cashuMints: decryptedCashuMints,
774 };
775
776 // Save session data
777 debug('handleUnlockRequest: Saving session data...');
778 await browser.storage.session.set(browserSessionData as unknown as Record<string, unknown>);
779 debug('handleUnlockRequest: Unlock complete!');
780
781 return { success: true };
782 } catch (error: any) {
783 debug(`handleUnlockRequest: Error: ${error.message}`);
784 return { success: false, error: error.message || 'Unlock failed' };
785 }
786 }
787
788 /**
789 * Open the unlock popup window
790 */
791 export async function openUnlockPopup(host?: string): Promise<number | undefined> {
792 const width = 375;
793 const height = 500;
794 const { top, left } = await getPosition(width, height);
795
796 const id = crypto.randomUUID();
797 let url = `unlock.html?id=${id}`;
798 if (host) {
799 url += `&host=${encodeURIComponent(host)}`;
800 }
801
802 const win = await browser.windows.create({
803 type: 'popup',
804 url,
805 height,
806 width,
807 top,
808 left,
809 });
810 return win.id;
811 }
812