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