/* eslint-disable @typescript-eslint/no-explicit-any */ import { Injectable } from '@angular/core'; import { BrowserSyncHandler } from './browser-sync-handler'; import { BrowserSessionHandler } from './browser-session-handler'; import { VaultSession, EncryptedVault, SyncFlow, ExtensionSettings, RelayData, CashuMintRecord, CashuProof, } from './types'; import { SignerMetaHandler } from './signer-meta-handler'; import { CryptoHelper } from '@common'; import { Buffer } from 'buffer'; import { addIdentity, deleteIdentity, switchIdentity, } from './related/identity'; import { deletePermission } from './related/permission'; import { createNewVault, deleteVault, unlockVault } from './related/vault'; import { addRelay, deleteRelay, updateRelay } from './related/relay'; import { addNwcConnection, deleteNwcConnection, updateNwcConnectionBalance, } from './related/nwc'; import { addCashuMint, deleteCashuMint, updateCashuMintProofs, } from './related/cashu'; export interface StorageServiceConfig { browserSessionHandler: BrowserSessionHandler; browserSyncYesHandler: BrowserSyncHandler; browserSyncNoHandler: BrowserSyncHandler; signerMetaHandler: SignerMetaHandler; } @Injectable({ providedIn: 'root', }) export class StorageService { readonly latestVersion = 2; isInitialized = false; #browserSessionHandler!: BrowserSessionHandler; #browserSyncYesHandler!: BrowserSyncHandler; #browserSyncNoHandler!: BrowserSyncHandler; #signerMetaHandler!: SignerMetaHandler; initialize(config: StorageServiceConfig): void { if (this.isInitialized) { return; } this.#browserSessionHandler = config.browserSessionHandler; this.#browserSyncYesHandler = config.browserSyncYesHandler; this.#browserSyncNoHandler = config.browserSyncNoHandler; this.#signerMetaHandler = config.signerMetaHandler; this.isInitialized = true; } async enableBrowserSyncFlow(flow: SyncFlow): Promise { this.assureIsInitialized(); this.#signerMetaHandler.setSyncFlow(flow); } async loadExtensionSettings(): Promise { this.assureIsInitialized(); const data = await this.#signerMetaHandler.loadFullData(); if (Object.keys(data).length === 0) { // No data available yet. return undefined; } this.#signerMetaHandler.setFullData(data as ExtensionSettings); return data as ExtensionSettings; } /** @deprecated Use loadExtensionSettings instead */ async loadSignerMetaData(): Promise { return this.loadExtensionSettings(); } async loadVaultSession(): Promise { this.assureIsInitialized(); const data = await this.#browserSessionHandler.loadFullData(); if (Object.keys(data).length === 0) { // No data available yet (e.g. because the vault was not unlocked). return undefined; } // Set the existing data for in-memory usage. this.#browserSessionHandler.setFullData(data as VaultSession); return data as VaultSession; } /** @deprecated Use loadVaultSession instead */ async loadBrowserSessionData(): Promise { return this.loadVaultSession(); } /** * Load and migrate the encrypted vault data. If no data is available yet, * the returned object is undefined. */ async loadAndMigrateEncryptedVault(): Promise { this.assureIsInitialized(); const unmigratedEncryptedVault = await this.getBrowserSyncHandler().loadUnmigratedData(); const { encryptedVault, migrationWasPerformed } = this.#migrateEncryptedVault(unmigratedEncryptedVault); if (!encryptedVault) { // Nothing to do at this point. return undefined; } // There is data. Check, if it was migrated. if (migrationWasPerformed) { // Persist the migrated data back to the browser sync storage. this.getBrowserSyncHandler().saveAndSetFullData(encryptedVault); } else { // Set the data for in-memory usage. this.getBrowserSyncHandler().setFullData(encryptedVault); } return encryptedVault; } /** @deprecated Use loadAndMigrateEncryptedVault instead */ async loadAndMigrateBrowserSyncData(): Promise { return this.loadAndMigrateEncryptedVault(); } async deleteVault(doNotSetIsInitializedToFalse = false) { await deleteVault.call(this, doNotSetIsInitializedToFalse); } async resetExtension() { this.assureIsInitialized(); await this.getBrowserSyncHandler().clearData(); await this.getBrowserSessionHandler().clearData(); await this.getSignerMetaHandler().clearData([]); this.isInitialized = false; } async lockVault(): Promise { this.assureIsInitialized(); await this.getBrowserSessionHandler().clearData(); this.getBrowserSessionHandler().clearInMemoryData(); // Note: We don't set isInitialized = false here because the sync data // (encrypted vault) is still loaded and we need it to unlock again } async unlockVault(password: string): Promise { await unlockVault.call(this, password); } async createNewVault(password: string): Promise { await createNewVault.call(this, password); } async addIdentity(data: { nick: string; privkeyString: string; }): Promise { await addIdentity.call(this, data); } async deleteIdentity(identityId: string | undefined): Promise { await deleteIdentity.call(this, identityId); } async switchIdentity(identityId: string | null): Promise { await switchIdentity.call(this, identityId); } async deletePermission(permissionId: string) { await deletePermission.call(this, permissionId); } async addRelay(data: { identityId: string; url: string; write: boolean; read: boolean; }): Promise { await addRelay.call(this, data); } async deleteRelay(relayId: string): Promise { await deleteRelay.call(this, relayId); } async updateRelay(relayClone: RelayData): Promise { await updateRelay.call(this, relayClone); } async addNwcConnection(data: { name: string; connectionUrl: string; }): Promise { await addNwcConnection.call(this, data); } async deleteNwcConnection(connectionId: string): Promise { await deleteNwcConnection.call(this, connectionId); } async updateNwcConnectionBalance( connectionId: string, balanceMillisats: number ): Promise { await updateNwcConnectionBalance.call(this, connectionId, balanceMillisats); } async addCashuMint(data: { name: string; mintUrl: string; unit?: string; }): Promise { return await addCashuMint.call(this, data); } async deleteCashuMint(mintId: string): Promise { await deleteCashuMint.call(this, mintId); } async updateCashuMintProofs( mintId: string, proofs: CashuProof[] ): Promise { await updateCashuMintProofs.call(this, mintId, proofs); } exportVault(): string { this.assureIsInitialized(); const vaultJson = JSON.stringify( this.getBrowserSyncHandler().encryptedVault, undefined, 4 ); return vaultJson; } async importVault(allegedEncryptedVault: EncryptedVault) { this.assureIsInitialized(); const isValidData = this.#allegedEncryptedVaultIsValid( allegedEncryptedVault ); if (!isValidData) { throw new Error('The imported data is not valid.'); } await this.getBrowserSyncHandler().saveAndSetFullData( allegedEncryptedVault ); } getBrowserSyncHandler(): BrowserSyncHandler { this.assureIsInitialized(); switch (this.#signerMetaHandler.extensionSettings?.syncFlow) { case SyncFlow.NO_SYNC: return this.#browserSyncNoHandler; case SyncFlow.BROWSER_SYNC: default: return this.#browserSyncYesHandler; } } getBrowserSessionHandler(): BrowserSessionHandler { this.assureIsInitialized(); return this.#browserSessionHandler; } getSignerMetaHandler(): SignerMetaHandler { this.assureIsInitialized(); return this.#signerMetaHandler; } /** * Get the current sync flow setting. * Returns NO_SYNC if not initialized or no setting found. */ getSyncFlow(): SyncFlow { if (!this.isInitialized || !this.#signerMetaHandler?.extensionSettings) { return SyncFlow.NO_SYNC; } return this.#signerMetaHandler.extensionSettings.syncFlow ?? SyncFlow.NO_SYNC; } /** * Throws an exception if the service is not initialized. */ assureIsInitialized(): void { if (!this.isInitialized) { throw new Error( 'StorageService is not initialized. Please call "initialize(...)" before doing anything else.' ); } } async encrypt(value: string): Promise { const vaultSession = this.getBrowserSessionHandler().vaultSession; if (!vaultSession) { throw new Error('Vault session is undefined.'); } // v2: Use pre-derived key directly with AES-GCM if (vaultSession.vaultKey) { return this.encryptV2(value, vaultSession.iv, vaultSession.vaultKey); } // v1: Use PBKDF2 with password if (!vaultSession.vaultPassword) { throw new Error('No vault password or key available.'); } return CryptoHelper.encrypt( value, vaultSession.iv, vaultSession.vaultPassword ); } /** * v2 encryption: Use pre-derived key bytes directly with AES-GCM (no key derivation) */ async encryptV2(text: string, ivBase64: string, keyBase64: string): Promise { const keyBytes = Buffer.from(keyBase64, 'base64'); const iv = Buffer.from(ivBase64, 'base64'); const key = await crypto.subtle.importKey( 'raw', keyBytes, { name: 'AES-GCM' }, false, ['encrypt'] ); const cipherText = await crypto.subtle.encrypt( { name: 'AES-GCM', iv }, key, new TextEncoder().encode(text) ); return Buffer.from(cipherText).toString('base64'); } async decrypt( value: string, returnType: 'string' | 'number' | 'boolean' ): Promise { const vaultSession = this.getBrowserSessionHandler().vaultSession; if (!vaultSession) { throw new Error('Vault session is undefined.'); } // v2: Use pre-derived key directly with AES-GCM if (vaultSession.vaultKey) { const decryptedValue = await this.decryptV2( value, vaultSession.iv, vaultSession.vaultKey ); return this.parseDecryptedValue(decryptedValue, returnType); } // v1: Use PBKDF2 with password if (!vaultSession.vaultPassword) { throw new Error('No vault password or key available.'); } return this.decryptWithLockedVault( value, returnType, vaultSession.iv, vaultSession.vaultPassword ); } /** * v2 decryption: Use pre-derived key bytes directly with AES-GCM (no key derivation) */ async decryptV2(encryptedBase64: string, ivBase64: string, keyBase64: string): Promise { const keyBytes = Buffer.from(keyBase64, 'base64'); const iv = Buffer.from(ivBase64, 'base64'); const cipherText = Buffer.from(encryptedBase64, 'base64'); const key = await crypto.subtle.importKey( 'raw', keyBytes, { name: 'AES-GCM' }, false, ['decrypt'] ); const decrypted = await crypto.subtle.decrypt( { name: 'AES-GCM', iv }, key, cipherText ); return new TextDecoder().decode(decrypted); } /** * Parse a decrypted string value into the desired type */ private parseDecryptedValue( decryptedValue: string, returnType: 'string' | 'number' | 'boolean' ): any { switch (returnType) { case 'number': return parseInt(decryptedValue); case 'boolean': return decryptedValue === 'true'; case 'string': default: return decryptedValue; } } /** * v1: Decrypt with locked vault using password (PBKDF2) */ async decryptWithLockedVault( value: string, returnType: 'string' | 'number' | 'boolean', iv: string, password: string ): Promise { const decryptedValue = await CryptoHelper.decrypt(value, iv, password); return this.parseDecryptedValue(decryptedValue, returnType); } /** * v2: Decrypt with locked vault using pre-derived key (Argon2id) */ async decryptWithLockedVaultV2( value: string, returnType: 'string' | 'number' | 'boolean', iv: string, keyBase64: string ): Promise { const decryptedValue = await this.decryptV2(value, iv, keyBase64); return this.parseDecryptedValue(decryptedValue, returnType); } /** * Migrate the encrypted vault to the latest version. */ #migrateEncryptedVault(encryptedVault: Partial>): { encryptedVault?: EncryptedVault; migrationWasPerformed: boolean; } { if (Object.keys(encryptedVault).length === 0) { // First run. There is no encrypted vault yet. return { encryptedVault: undefined, migrationWasPerformed: false, }; } // Will be implemented if migration is required. return { encryptedVault: encryptedVault as EncryptedVault, migrationWasPerformed: false, }; } #allegedEncryptedVaultIsValid(data: EncryptedVault): boolean { if (typeof data.iv === 'undefined') { return false; } if (typeof data.version !== 'number') { return false; } if (typeof data.vaultHash === 'undefined') { return false; } if (typeof data.selectedIdentityId === 'undefined') { return false; } if ( typeof data.identities === 'undefined' || !Array.isArray(data.identities) ) { return false; } if ( typeof data.permissions === 'undefined' || !Array.isArray(data.permissions) ) { return false; } if (typeof data.relays === 'undefined' || !Array.isArray(data.relays)) { return false; } return true; } }