storage.service.ts raw

   1  /* eslint-disable @typescript-eslint/no-explicit-any */
   2  import { Injectable } from '@angular/core';
   3  import { BrowserSyncHandler } from './browser-sync-handler';
   4  import { BrowserSessionHandler } from './browser-session-handler';
   5  import {
   6    VaultSession,
   7    EncryptedVault,
   8    SyncFlow,
   9    ExtensionSettings,
  10    RelayData,
  11    CashuMintRecord,
  12    CashuProof,
  13  } from './types';
  14  import { SignerMetaHandler } from './signer-meta-handler';
  15  import { CryptoHelper } from '@common';
  16  import { Buffer } from 'buffer';
  17  import {
  18    addIdentity,
  19    deleteIdentity,
  20    switchIdentity,
  21  } from './related/identity';
  22  import { deletePermission } from './related/permission';
  23  import { changePassword, createNewVault, deleteVault, unlockVault } from './related/vault';
  24  import { addRelay, deleteRelay, updateRelay } from './related/relay';
  25  import {
  26    addNwcConnection,
  27    deleteNwcConnection,
  28    updateNwcConnectionBalance,
  29  } from './related/nwc';
  30  import {
  31    addCashuMint,
  32    deleteCashuMint,
  33    updateCashuMintProofs,
  34  } from './related/cashu';
  35  
  36  export interface StorageServiceConfig {
  37    browserSessionHandler: BrowserSessionHandler;
  38    browserSyncYesHandler: BrowserSyncHandler;
  39    browserSyncNoHandler: BrowserSyncHandler;
  40    signerMetaHandler: SignerMetaHandler;
  41  }
  42  
  43  @Injectable({
  44    providedIn: 'root',
  45  })
  46  export class StorageService {
  47    readonly latestVersion = 2;
  48    isInitialized = false;
  49  
  50    #browserSessionHandler!: BrowserSessionHandler;
  51    #browserSyncYesHandler!: BrowserSyncHandler;
  52    #browserSyncNoHandler!: BrowserSyncHandler;
  53    #signerMetaHandler!: SignerMetaHandler;
  54  
  55    initialize(config: StorageServiceConfig): void {
  56      if (this.isInitialized) {
  57        return;
  58      }
  59      this.#browserSessionHandler = config.browserSessionHandler;
  60      this.#browserSyncYesHandler = config.browserSyncYesHandler;
  61      this.#browserSyncNoHandler = config.browserSyncNoHandler;
  62      this.#signerMetaHandler = config.signerMetaHandler;
  63      this.isInitialized = true;
  64    }
  65  
  66    async enableBrowserSyncFlow(flow: SyncFlow): Promise<void> {
  67      this.assureIsInitialized();
  68  
  69      this.#signerMetaHandler.setSyncFlow(flow);
  70    }
  71  
  72    async loadExtensionSettings(): Promise<ExtensionSettings | undefined> {
  73      this.assureIsInitialized();
  74  
  75      const data = await this.#signerMetaHandler.loadFullData();
  76      if (Object.keys(data).length === 0) {
  77        // No data available yet.
  78        return undefined;
  79      }
  80  
  81      this.#signerMetaHandler.setFullData(data as ExtensionSettings);
  82      return data as ExtensionSettings;
  83    }
  84  
  85    /** @deprecated Use loadExtensionSettings instead */
  86    async loadSignerMetaData(): Promise<ExtensionSettings | undefined> {
  87      return this.loadExtensionSettings();
  88    }
  89  
  90    async loadVaultSession(): Promise<VaultSession | undefined> {
  91      this.assureIsInitialized();
  92  
  93      const data = await this.#browserSessionHandler.loadFullData();
  94      // Session storage may contain non-vault data (logs, profile cache, relay cache).
  95      // Only treat as unlocked vault if the required keys are present.
  96      if (!data['iv'] || !data['identities']) {
  97        return undefined;
  98      }
  99  
 100      // Set the existing data for in-memory usage.
 101      this.#browserSessionHandler.setFullData(data as VaultSession);
 102      return data as VaultSession;
 103    }
 104  
 105    /** @deprecated Use loadVaultSession instead */
 106    async loadBrowserSessionData(): Promise<VaultSession | undefined> {
 107      return this.loadVaultSession();
 108    }
 109  
 110    /**
 111     * Load and migrate the encrypted vault data. If no data is available yet,
 112     * the returned object is undefined.
 113     */
 114    async loadAndMigrateEncryptedVault(): Promise<EncryptedVault | undefined> {
 115      this.assureIsInitialized();
 116      const unmigratedEncryptedVault =
 117        await this.getBrowserSyncHandler().loadUnmigratedData();
 118      const { encryptedVault, migrationWasPerformed } =
 119        this.#migrateEncryptedVault(unmigratedEncryptedVault);
 120  
 121      if (!encryptedVault) {
 122        // Nothing to do at this point.
 123        return undefined;
 124      }
 125  
 126      // There is data. Check, if it was migrated.
 127      if (migrationWasPerformed) {
 128        // Persist the migrated data back to the browser sync storage.
 129        this.getBrowserSyncHandler().saveAndSetFullData(encryptedVault);
 130      } else {
 131        // Set the data for in-memory usage.
 132        this.getBrowserSyncHandler().setFullData(encryptedVault);
 133      }
 134  
 135      return encryptedVault;
 136    }
 137  
 138    /** @deprecated Use loadAndMigrateEncryptedVault instead */
 139    async loadAndMigrateBrowserSyncData(): Promise<EncryptedVault | undefined> {
 140      return this.loadAndMigrateEncryptedVault();
 141    }
 142  
 143    async deleteVault(doNotSetIsInitializedToFalse = false) {
 144      await deleteVault.call(this, doNotSetIsInitializedToFalse);
 145    }
 146  
 147    async resetExtension() {
 148      this.assureIsInitialized();
 149      await this.getBrowserSyncHandler().clearData();
 150      await this.getBrowserSessionHandler().clearData();
 151      await this.getSignerMetaHandler().clearData([]);
 152      this.isInitialized = false;
 153    }
 154  
 155    async lockVault(): Promise<void> {
 156      this.assureIsInitialized();
 157      await this.getBrowserSessionHandler().clearData();
 158      this.getBrowserSessionHandler().clearInMemoryData();
 159      // Note: We don't set isInitialized = false here because the sync data
 160      // (encrypted vault) is still loaded and we need it to unlock again
 161    }
 162  
 163    async unlockVault(password: string): Promise<void> {
 164      await unlockVault.call(this, password);
 165    }
 166  
 167    async createNewVault(password: string): Promise<void> {
 168      await createNewVault.call(this, password);
 169    }
 170  
 171    async changePassword(newPassword: string): Promise<void> {
 172      await changePassword.call(this, newPassword);
 173    }
 174  
 175    async addIdentity(data: {
 176      nick: string;
 177      privkeyString: string;
 178    }): Promise<void> {
 179      await addIdentity.call(this, data);
 180    }
 181  
 182    async deleteIdentity(identityId: string | undefined): Promise<void> {
 183      await deleteIdentity.call(this, identityId);
 184    }
 185  
 186    async switchIdentity(identityId: string | null): Promise<void> {
 187      await switchIdentity.call(this, identityId);
 188    }
 189  
 190    async deletePermission(permissionId: string) {
 191      await deletePermission.call(this, permissionId);
 192    }
 193  
 194    async addRelay(data: {
 195      identityId: string;
 196      url: string;
 197      write: boolean;
 198      read: boolean;
 199    }): Promise<void> {
 200      await addRelay.call(this, data);
 201    }
 202  
 203    async deleteRelay(relayId: string): Promise<void> {
 204      await deleteRelay.call(this, relayId);
 205    }
 206  
 207    async updateRelay(relayClone: RelayData): Promise<void> {
 208      await updateRelay.call(this, relayClone);
 209    }
 210  
 211    async addNwcConnection(data: {
 212      name: string;
 213      connectionUrl: string;
 214    }): Promise<void> {
 215      await addNwcConnection.call(this, data);
 216    }
 217  
 218    async deleteNwcConnection(connectionId: string): Promise<void> {
 219      await deleteNwcConnection.call(this, connectionId);
 220    }
 221  
 222    async updateNwcConnectionBalance(
 223      connectionId: string,
 224      balanceMillisats: number
 225    ): Promise<void> {
 226      await updateNwcConnectionBalance.call(this, connectionId, balanceMillisats);
 227    }
 228  
 229    async addCashuMint(data: {
 230      name: string;
 231      mintUrl: string;
 232      unit?: string;
 233    }): Promise<CashuMintRecord> {
 234      return await addCashuMint.call(this, data);
 235    }
 236  
 237    async deleteCashuMint(mintId: string): Promise<void> {
 238      await deleteCashuMint.call(this, mintId);
 239    }
 240  
 241    async updateCashuMintProofs(
 242      mintId: string,
 243      proofs: CashuProof[]
 244    ): Promise<void> {
 245      await updateCashuMintProofs.call(this, mintId, proofs);
 246    }
 247  
 248    exportVault(): string {
 249      this.assureIsInitialized();
 250      const vaultJson = JSON.stringify(
 251        this.getBrowserSyncHandler().encryptedVault,
 252        undefined,
 253        4
 254      );
 255      return vaultJson;
 256    }
 257  
 258    async importVault(allegedEncryptedVault: EncryptedVault) {
 259      this.assureIsInitialized();
 260  
 261      const isValidData = this.#allegedEncryptedVaultIsValid(
 262        allegedEncryptedVault
 263      );
 264      if (!isValidData) {
 265        throw new Error('The imported data is not valid.');
 266      }
 267  
 268      await this.getBrowserSyncHandler().saveAndSetFullData(
 269        allegedEncryptedVault
 270      );
 271    }
 272  
 273    getBrowserSyncHandler(): BrowserSyncHandler {
 274      this.assureIsInitialized();
 275  
 276      switch (this.#signerMetaHandler.extensionSettings?.syncFlow) {
 277        case SyncFlow.NO_SYNC:
 278          return this.#browserSyncNoHandler;
 279  
 280        case SyncFlow.BROWSER_SYNC:
 281        default:
 282          return this.#browserSyncYesHandler;
 283      }
 284    }
 285  
 286    getBrowserSessionHandler(): BrowserSessionHandler {
 287      this.assureIsInitialized();
 288  
 289      return this.#browserSessionHandler;
 290    }
 291  
 292    getSignerMetaHandler(): SignerMetaHandler {
 293      this.assureIsInitialized();
 294  
 295      return this.#signerMetaHandler;
 296    }
 297  
 298    /**
 299     * Get the current sync flow setting.
 300     * Returns NO_SYNC if not initialized or no setting found.
 301     */
 302    getSyncFlow(): SyncFlow {
 303      if (!this.isInitialized || !this.#signerMetaHandler?.extensionSettings) {
 304        return SyncFlow.NO_SYNC;
 305      }
 306      return this.#signerMetaHandler.extensionSettings.syncFlow ?? SyncFlow.NO_SYNC;
 307    }
 308  
 309    /**
 310     * Throws an exception if the service is not initialized.
 311     */
 312    assureIsInitialized(): void {
 313      if (!this.isInitialized) {
 314        throw new Error(
 315          'StorageService is not initialized. Please call "initialize(...)" before doing anything else.'
 316        );
 317      }
 318    }
 319  
 320    async encrypt(value: string): Promise<string> {
 321      const vaultSession = this.getBrowserSessionHandler().vaultSession;
 322      if (!vaultSession) {
 323        throw new Error('Vault session is undefined.');
 324      }
 325  
 326      // v2: Use pre-derived key directly with AES-GCM
 327      if (vaultSession.vaultKey) {
 328        return this.encryptV2(value, vaultSession.iv, vaultSession.vaultKey);
 329      }
 330  
 331      // v1: Use PBKDF2 with password
 332      if (!vaultSession.vaultPassword) {
 333        throw new Error('No vault password or key available.');
 334      }
 335      return CryptoHelper.encrypt(
 336        value,
 337        vaultSession.iv,
 338        vaultSession.vaultPassword
 339      );
 340    }
 341  
 342    /**
 343     * v2 encryption: Use pre-derived key bytes directly with AES-GCM (no key derivation)
 344     */
 345    async encryptV2(text: string, ivBase64: string, keyBase64: string): Promise<string> {
 346      const keyBytes = Buffer.from(keyBase64, 'base64');
 347      const iv = Buffer.from(ivBase64, 'base64');
 348  
 349      const key = await crypto.subtle.importKey(
 350        'raw',
 351        keyBytes,
 352        { name: 'AES-GCM' },
 353        false,
 354        ['encrypt']
 355      );
 356  
 357      const cipherText = await crypto.subtle.encrypt(
 358        { name: 'AES-GCM', iv },
 359        key,
 360        new TextEncoder().encode(text)
 361      );
 362  
 363      return Buffer.from(cipherText).toString('base64');
 364    }
 365  
 366    async decrypt(
 367      value: string,
 368      returnType: 'string' | 'number' | 'boolean'
 369    ): Promise<any> {
 370      const vaultSession = this.getBrowserSessionHandler().vaultSession;
 371      if (!vaultSession) {
 372        throw new Error('Vault session is undefined.');
 373      }
 374  
 375      // v2: Use pre-derived key directly with AES-GCM
 376      if (vaultSession.vaultKey) {
 377        const decryptedValue = await this.decryptV2(
 378          value,
 379          vaultSession.iv,
 380          vaultSession.vaultKey
 381        );
 382        return this.parseDecryptedValue(decryptedValue, returnType);
 383      }
 384  
 385      // v1: Use PBKDF2 with password
 386      if (!vaultSession.vaultPassword) {
 387        throw new Error('No vault password or key available.');
 388      }
 389      return this.decryptWithLockedVault(
 390        value,
 391        returnType,
 392        vaultSession.iv,
 393        vaultSession.vaultPassword
 394      );
 395    }
 396  
 397    /**
 398     * v2 decryption: Use pre-derived key bytes directly with AES-GCM (no key derivation)
 399     */
 400    async decryptV2(encryptedBase64: string, ivBase64: string, keyBase64: string): Promise<string> {
 401      const keyBytes = Buffer.from(keyBase64, 'base64');
 402      const iv = Buffer.from(ivBase64, 'base64');
 403      const cipherText = Buffer.from(encryptedBase64, 'base64');
 404  
 405      const key = await crypto.subtle.importKey(
 406        'raw',
 407        keyBytes,
 408        { name: 'AES-GCM' },
 409        false,
 410        ['decrypt']
 411      );
 412  
 413      const decrypted = await crypto.subtle.decrypt(
 414        { name: 'AES-GCM', iv },
 415        key,
 416        cipherText
 417      );
 418  
 419      return new TextDecoder().decode(decrypted);
 420    }
 421  
 422    /**
 423     * Parse a decrypted string value into the desired type
 424     */
 425    private parseDecryptedValue(
 426      decryptedValue: string,
 427      returnType: 'string' | 'number' | 'boolean'
 428    ): any {
 429      switch (returnType) {
 430        case 'number':
 431          return parseInt(decryptedValue);
 432        case 'boolean':
 433          return decryptedValue === 'true';
 434        case 'string':
 435        default:
 436          return decryptedValue;
 437      }
 438    }
 439  
 440    /**
 441     * v1: Decrypt with locked vault using password (PBKDF2)
 442     */
 443    async decryptWithLockedVault(
 444      value: string,
 445      returnType: 'string' | 'number' | 'boolean',
 446      iv: string,
 447      password: string
 448    ): Promise<any> {
 449      const decryptedValue = await CryptoHelper.decrypt(value, iv, password);
 450      return this.parseDecryptedValue(decryptedValue, returnType);
 451    }
 452  
 453    /**
 454     * v2: Decrypt with locked vault using pre-derived key (Argon2id)
 455     */
 456    async decryptWithLockedVaultV2(
 457      value: string,
 458      returnType: 'string' | 'number' | 'boolean',
 459      iv: string,
 460      keyBase64: string
 461    ): Promise<any> {
 462      const decryptedValue = await this.decryptV2(value, iv, keyBase64);
 463      return this.parseDecryptedValue(decryptedValue, returnType);
 464    }
 465  
 466    /**
 467     * Migrate the encrypted vault to the latest version.
 468     */
 469    #migrateEncryptedVault(encryptedVault: Partial<Record<string, any>>): {
 470      encryptedVault?: EncryptedVault;
 471      migrationWasPerformed: boolean;
 472    } {
 473      if (Object.keys(encryptedVault).length === 0) {
 474        // First run. There is no encrypted vault yet.
 475        return {
 476          encryptedVault: undefined,
 477          migrationWasPerformed: false,
 478        };
 479      }
 480  
 481      // Will be implemented if migration is required.
 482      return {
 483        encryptedVault: encryptedVault as EncryptedVault,
 484        migrationWasPerformed: false,
 485      };
 486    }
 487  
 488    #allegedEncryptedVaultIsValid(data: EncryptedVault): boolean {
 489      if (typeof data.iv === 'undefined') {
 490        return false;
 491      }
 492  
 493      if (typeof data.version !== 'number') {
 494        return false;
 495      }
 496  
 497      if (typeof data.vaultHash === 'undefined') {
 498        return false;
 499      }
 500  
 501      if (typeof data.selectedIdentityId === 'undefined') {
 502        return false;
 503      }
 504  
 505      if (
 506        typeof data.identities === 'undefined' ||
 507        !Array.isArray(data.identities)
 508      ) {
 509        return false;
 510      }
 511  
 512      if (
 513        typeof data.permissions === 'undefined' ||
 514        !Array.isArray(data.permissions)
 515      ) {
 516        return false;
 517      }
 518  
 519      if (typeof data.relays === 'undefined' || !Array.isArray(data.relays)) {
 520        return false;
 521      }
 522  
 523      return true;
 524    }
 525  }
 526