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