Files
plebeian-signer/projects/common/src/lib/services/storage/storage.service.ts
woikos d98a0ef76e Implement DDD refactoring phases 1-4 with domain layer and ubiquitous language
Phase 1-3: Domain Layer Foundation
- Add value objects: IdentityId, PermissionId, RelayId, WalletId, Nickname, NostrKeyPair
- Add rich domain entities: Identity, Permission, Relay with behavior
- Add domain events: IdentityCreated, IdentityRenamed, IdentitySelected, etc.
- Add repository interfaces for Identity, Permission, Relay
- Add infrastructure layer with repository implementations
- Add EncryptionService abstraction

Phase 4: Ubiquitous Language Cleanup
- Rename BrowserSyncData → EncryptedVault (encrypted vault storage)
- Rename BrowserSessionData → VaultSession (decrypted session state)
- Rename SignerMetaData → ExtensionSettings (extension configuration)
- Rename Identity_ENCRYPTED → StoredIdentity (storage DTO)
- Rename Identity_DECRYPTED → IdentityData (session DTO)
- Similar renames for Permission, Relay, NwcConnection, CashuMint
- Add backwards compatibility aliases with @deprecated markers

Test Coverage
- Add comprehensive tests for all value objects
- Add tests for domain entities and their behavior
- Add tests for domain events
- Fix PermissionChecker to prioritize kind-specific rules over blanket rules
- Fix pre-existing component test issues (IconButton, Pubkey)

All 113 tests pass. Both Chrome and Firefox builds succeed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 05:21:44 +01:00

521 lines
14 KiB
TypeScript

/* 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<void> {
this.assureIsInitialized();
this.#signerMetaHandler.setSyncFlow(flow);
}
async loadExtensionSettings(): Promise<ExtensionSettings | undefined> {
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<ExtensionSettings | undefined> {
return this.loadExtensionSettings();
}
async loadVaultSession(): Promise<VaultSession | undefined> {
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<VaultSession | undefined> {
return this.loadVaultSession();
}
/**
* Load and migrate the encrypted vault data. If no data is available yet,
* the returned object is undefined.
*/
async loadAndMigrateEncryptedVault(): Promise<EncryptedVault | undefined> {
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<EncryptedVault | undefined> {
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<void> {
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<void> {
await unlockVault.call(this, password);
}
async createNewVault(password: string): Promise<void> {
await createNewVault.call(this, password);
}
async addIdentity(data: {
nick: string;
privkeyString: string;
}): Promise<void> {
await addIdentity.call(this, data);
}
async deleteIdentity(identityId: string | undefined): Promise<void> {
await deleteIdentity.call(this, identityId);
}
async switchIdentity(identityId: string | null): Promise<void> {
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<void> {
await addRelay.call(this, data);
}
async deleteRelay(relayId: string): Promise<void> {
await deleteRelay.call(this, relayId);
}
async updateRelay(relayClone: RelayData): Promise<void> {
await updateRelay.call(this, relayClone);
}
async addNwcConnection(data: {
name: string;
connectionUrl: string;
}): Promise<void> {
await addNwcConnection.call(this, data);
}
async deleteNwcConnection(connectionId: string): Promise<void> {
await deleteNwcConnection.call(this, connectionId);
}
async updateNwcConnectionBalance(
connectionId: string,
balanceMillisats: number
): Promise<void> {
await updateNwcConnectionBalance.call(this, connectionId, balanceMillisats);
}
async addCashuMint(data: {
name: string;
mintUrl: string;
unit?: string;
}): Promise<CashuMintRecord> {
return await addCashuMint.call(this, data);
}
async deleteCashuMint(mintId: string): Promise<void> {
await deleteCashuMint.call(this, mintId);
}
async updateCashuMintProofs(
mintId: string,
proofs: CashuProof[]
): Promise<void> {
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<string> {
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<string> {
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<any> {
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<string> {
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<any> {
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<any> {
const decryptedValue = await this.decryptV2(value, iv, keyBase64);
return this.parseDecryptedValue(decryptedValue, returnType);
}
/**
* Migrate the encrypted vault to the latest version.
*/
#migrateEncryptedVault(encryptedVault: Partial<Record<string, any>>): {
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;
}
}