From d98a0ef76e3b86029c4413ee3f72a7caa24b4245 Mon Sep 17 00:00:00 2001 From: woikos Date: Thu, 25 Dec 2025 05:21:44 +0100 Subject: [PATCH] Implement DDD refactoring phases 1-4 with domain layer and ubiquitous language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- DDD_ANALYSIS.md | 690 ++++++++++++++++++ .../app/common/data/chrome-meta-handler.ts | 4 +- .../app/common/data/chrome-session-handler.ts | 4 +- .../app/common/data/chrome-sync-no-handler.ts | 24 +- .../common/data/chrome-sync-yes-handler.ts | 24 +- .../permissions/permissions.component.html | 2 +- .../permissions/permissions.component.ts | 5 + .../icon-button/icon-button.component.spec.ts | 1 + .../pubkey/pubkey.component.spec.ts | 2 + .../src/lib/domain/entities/identity.spec.ts | 200 +++++ .../src/lib/domain/entities/identity.ts | 305 ++++++++ .../common/src/lib/domain/entities/index.ts | 21 + .../lib/domain/entities/permission.spec.ts | 175 +++++ .../src/lib/domain/entities/permission.ts | 332 +++++++++ .../src/lib/domain/entities/relay.spec.ts | 155 ++++ .../common/src/lib/domain/entities/relay.ts | 268 +++++++ .../lib/domain/events/domain-event.spec.ts | 81 ++ .../src/lib/domain/events/domain-event.ts | 55 ++ .../lib/domain/events/identity-events.spec.ts | 110 +++ .../src/lib/domain/events/identity-events.ts | 74 ++ .../common/src/lib/domain/events/index.ts | 9 + projects/common/src/lib/domain/index.ts | 11 + .../repositories/identity-repository.ts | 89 +++ .../src/lib/domain/repositories/index.ts | 30 + .../repositories/permission-repository.ts | 108 +++ .../domain/repositories/relay-repository.ts | 94 +++ .../domain/value-objects/entity-id.spec.ts | 84 +++ .../src/lib/domain/value-objects/entity-id.ts | 30 + .../lib/domain/value-objects/identity-id.ts | 36 + .../src/lib/domain/value-objects/index.ts | 16 + .../lib/domain/value-objects/nickname.spec.ts | 95 +++ .../src/lib/domain/value-objects/nickname.ts | 66 ++ .../value-objects/nostr-keypair.spec.ts | 91 +++ .../lib/domain/value-objects/nostr-keypair.ts | 223 ++++++ .../lib/domain/value-objects/permission-id.ts | 36 + .../src/lib/domain/value-objects/relay-id.ts | 36 + .../src/lib/domain/value-objects/wallet-id.ts | 48 ++ .../encryption/encryption-context.ts | 67 ++ .../encryption/encryption.service.ts | 156 ++++ .../lib/infrastructure/encryption/index.ts | 15 + .../common/src/lib/infrastructure/index.ts | 2 + .../repositories/identity-repository.impl.ts | 228 ++++++ .../lib/infrastructure/repositories/index.ts | 17 + .../permission-repository.impl.ts | 218 ++++++ .../repositories/relay-repository.impl.ts | 219 ++++++ .../storage/browser-session-handler.ts | 21 +- .../services/storage/browser-sync-handler.ts | 73 +- .../services/storage/signer-meta-handler.ts | 132 ++-- .../lib/services/storage/storage.service.ts | 144 ++-- .../common/src/lib/services/storage/types.ts | 202 +++-- .../common/src/lib/styles/_typography.scss | 5 +- projects/common/src/public-api.ts | 7 + .../app/common/data/firefox-meta-handler.ts | 4 +- .../common/data/firefox-session-handler.ts | 4 +- .../common/data/firefox-sync-no-handler.ts | 24 +- .../common/data/firefox-sync-yes-handler.ts | 24 +- .../permissions/permissions.component.html | 2 +- .../permissions/permissions.component.ts | 6 +- 58 files changed, 4927 insertions(+), 277 deletions(-) create mode 100644 DDD_ANALYSIS.md create mode 100644 projects/common/src/lib/domain/entities/identity.spec.ts create mode 100644 projects/common/src/lib/domain/entities/identity.ts create mode 100644 projects/common/src/lib/domain/entities/index.ts create mode 100644 projects/common/src/lib/domain/entities/permission.spec.ts create mode 100644 projects/common/src/lib/domain/entities/permission.ts create mode 100644 projects/common/src/lib/domain/entities/relay.spec.ts create mode 100644 projects/common/src/lib/domain/entities/relay.ts create mode 100644 projects/common/src/lib/domain/events/domain-event.spec.ts create mode 100644 projects/common/src/lib/domain/events/domain-event.ts create mode 100644 projects/common/src/lib/domain/events/identity-events.spec.ts create mode 100644 projects/common/src/lib/domain/events/identity-events.ts create mode 100644 projects/common/src/lib/domain/events/index.ts create mode 100644 projects/common/src/lib/domain/index.ts create mode 100644 projects/common/src/lib/domain/repositories/identity-repository.ts create mode 100644 projects/common/src/lib/domain/repositories/index.ts create mode 100644 projects/common/src/lib/domain/repositories/permission-repository.ts create mode 100644 projects/common/src/lib/domain/repositories/relay-repository.ts create mode 100644 projects/common/src/lib/domain/value-objects/entity-id.spec.ts create mode 100644 projects/common/src/lib/domain/value-objects/entity-id.ts create mode 100644 projects/common/src/lib/domain/value-objects/identity-id.ts create mode 100644 projects/common/src/lib/domain/value-objects/index.ts create mode 100644 projects/common/src/lib/domain/value-objects/nickname.spec.ts create mode 100644 projects/common/src/lib/domain/value-objects/nickname.ts create mode 100644 projects/common/src/lib/domain/value-objects/nostr-keypair.spec.ts create mode 100644 projects/common/src/lib/domain/value-objects/nostr-keypair.ts create mode 100644 projects/common/src/lib/domain/value-objects/permission-id.ts create mode 100644 projects/common/src/lib/domain/value-objects/relay-id.ts create mode 100644 projects/common/src/lib/domain/value-objects/wallet-id.ts create mode 100644 projects/common/src/lib/infrastructure/encryption/encryption-context.ts create mode 100644 projects/common/src/lib/infrastructure/encryption/encryption.service.ts create mode 100644 projects/common/src/lib/infrastructure/encryption/index.ts create mode 100644 projects/common/src/lib/infrastructure/index.ts create mode 100644 projects/common/src/lib/infrastructure/repositories/identity-repository.impl.ts create mode 100644 projects/common/src/lib/infrastructure/repositories/index.ts create mode 100644 projects/common/src/lib/infrastructure/repositories/permission-repository.impl.ts create mode 100644 projects/common/src/lib/infrastructure/repositories/relay-repository.impl.ts diff --git a/DDD_ANALYSIS.md b/DDD_ANALYSIS.md new file mode 100644 index 0000000..dc9f86d --- /dev/null +++ b/DDD_ANALYSIS.md @@ -0,0 +1,690 @@ +# Domain-Driven Design Analysis: Plebeian Signer + +This document analyzes the Plebeian Signer codebase through the lens of Domain-Driven Design (DDD) principles, identifying bounded contexts, current patterns, anti-patterns, and providing actionable recommendations for improvement. + +## Executive Summary + +Plebeian Signer is a browser extension for Nostr identity management implementing NIP-07. The codebase has **good structural foundations** (monorepo with shared library, handler abstraction pattern) but suffers from several DDD anti-patterns: + +- **God Service**: `StorageService` handles too many responsibilities +- **Anemic Domain Models**: Types are data containers without behavior +- **Mixed Concerns**: Encryption logic interleaved with domain operations +- **Weak Ubiquitous Language**: Generic naming (`BrowserSyncData`) obscures domain concepts + +**Priority Recommendations:** +1. Extract domain aggregates with behavior (Identity, Vault, Wallet) +2. Separate encryption into an infrastructure layer +3. Introduce repository pattern for each aggregate +4. Rename types to reflect ubiquitous language + +--- + +## Domain Overview + +### Core Domain Problem + +> Enable users to manage multiple Nostr identities securely, sign events without exposing private keys to web applications, and interact with Lightning/Cashu wallets. + +### Subdomain Classification + +| Subdomain | Type | Rationale | +|-----------|------|-----------| +| **Identity & Signing** | Core | The differentiator - secure key management and NIP-07 implementation | +| **Permission Management** | Core | Critical security layer - controls what apps can do | +| **Vault Encryption** | Supporting | Necessary security but standard cryptographic patterns | +| **Wallet Integration** | Supporting | Extends functionality but not the core value proposition | +| **Profile Caching** | Generic | Standard caching pattern, could use any solution | +| **Relay Management** | Supporting | Per-identity configuration, fairly standard | + +--- + +## Bounded Contexts + +### Identified Contexts + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ CONTEXT MAP │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────────┐ Shared Kernel ┌──────────────────┐ │ +│ │ Vault Context │◄─────────(crypto)──────────►│ Identity Context │ │ +│ │ │ │ │ │ +│ │ - VaultState │ │ - Identity │ │ +│ │ - Encryption │ │ - KeyPair │ │ +│ │ - Migration │ │ - Signing │ │ +│ └────────┬─────────┘ └────────┬─────────┘ │ +│ │ │ │ +│ │ Customer/Supplier │ │ +│ ▼ ▼ │ +│ ┌──────────────────┐ ┌──────────────────┐ │ +│ │ Permission Ctx │ │ Wallet Context │ │ +│ │ │ │ │ │ +│ │ - Policy │ │ - NWC │ │ +│ │ - Host Rules │ │ - Cashu │ │ +│ │ - Method Auth │ │ - Lightning │ │ +│ └──────────────────┘ └──────────────────┘ │ +│ │ +│ ┌──────────────────┐ ┌──────────────────┐ │ +│ │ Relay Context │◄──── Conformist ────────────►│ Profile Context │ │ +│ │ │ │ │ │ +│ │ - Per-identity │ │ - Kind 0 cache │ │ +│ │ - Read/Write │ │ - Metadata │ │ +│ └──────────────────┘ └──────────────────┘ │ +│ │ +│ Legend: ◄──► Bidirectional, ──► Supplier direction │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### Context Definitions + +#### 1. Vault Context +**Responsibility:** Secure storage lifecycle - creation, locking, unlocking, encryption, migration. + +**Current Location:** `projects/common/src/lib/services/storage/related/vault.ts` + +**Key Concepts:** +- VaultState (locked/unlocked) +- EncryptionKey (Argon2id-derived) +- VaultVersion (migration support) +- Salt, IV (cryptographic parameters) + +**Language:** +| Term | Definition | +|------|------------| +| Vault | The encrypted container holding all sensitive data | +| Unlock | Derive key from password and decrypt vault contents | +| Lock | Clear session data, requiring password to access again | +| Migration | Upgrade vault encryption scheme (v1→v2) | + +#### 2. Identity Context +**Responsibility:** Nostr identity lifecycle and cryptographic operations. + +**Current Location:** `projects/common/src/lib/services/storage/related/identity.ts` + +**Key Concepts:** +- Identity (aggregates pubkey, privkey, nick) +- KeyPair (hex or nsec/npub representations) +- SelectedIdentity (current active identity) +- EventSigning (NIP-07 signEvent) + +**Language:** +| Term | Definition | +|------|------------| +| Identity | A Nostr keypair with a user-defined nickname | +| Selected Identity | The currently active identity for signing | +| Sign | Create schnorr signature for a Nostr event | +| Switch | Change the active identity | + +#### 3. Permission Context +**Responsibility:** Authorization decisions for NIP-07 method calls. + +**Current Location:** `projects/common/src/lib/services/storage/related/permission.ts` + +**Key Concepts:** +- PermissionPolicy (allow/deny) +- MethodPermission (per NIP-07 method) +- KindPermission (signEvent kind filtering) +- HostWhitelist (trusted domains) +- RecklessMode (auto-approve all) + +**Language:** +| Term | Definition | +|------|------------| +| Permission | A stored allow/deny decision for identity+host+method | +| Reckless Mode | Global setting to auto-approve all requests | +| Whitelist | Hosts that auto-approve without prompting | +| Prompt | UI asking user to authorize a request | + +#### 4. Wallet Context +**Responsibility:** Lightning and Cashu wallet operations. + +**Current Location:** +- `projects/common/src/lib/services/nwc/` +- `projects/common/src/lib/services/cashu/` +- `projects/common/src/lib/services/storage/related/nwc.ts` +- `projects/common/src/lib/services/storage/related/cashu.ts` + +**Key Concepts:** +- NwcConnection (NIP-47 wallet connect) +- CashuMint (ecash mint connection) +- CashuProof (unspent tokens) +- LightningInvoice, Keysend + +#### 5. Relay Context +**Responsibility:** Per-identity relay configuration. + +**Current Location:** `projects/common/src/lib/services/storage/related/relay.ts` + +**Key Concepts:** +- RelayConfiguration (URL + read/write permissions) +- IdentityRelays (relays scoped to an identity) + +#### 6. Profile Context +**Responsibility:** Caching Nostr profile metadata (kind 0 events). + +**Current Location:** `projects/common/src/lib/services/profile-metadata/` + +**Key Concepts:** +- ProfileMetadata (name, picture, nip05, etc.) +- MetadataCache (fetchedAt timestamp) + +--- + +## Current Architecture Analysis + +### What's Working Well + +1. **Monorepo Structure** + - Clean separation: `projects/common`, `projects/chrome`, `projects/firefox` + - Shared library via `@common` alias + - Browser-specific implementations isolated + +2. **Handler Abstraction (Adapter Pattern)** + ``` + StorageService + ├→ BrowserSessionHandler (abstract → ChromeSessionHandler, FirefoxSessionHandler) + ├→ BrowserSyncHandler (abstract → ChromeSyncYesHandler, ChromeSyncNoHandler, ...) + └→ SignerMetaHandler (abstract → ChromeMetaHandler, FirefoxMetaHandler) + ``` + This enables pluggable browser implementations - good DDD practice. + +3. **Encrypted/Decrypted Type Pairs** + - `Identity_DECRYPTED` / `Identity_ENCRYPTED` + - Clear distinction between storage states + +4. **Vault Versioning** + - Migration path from v1 (PBKDF2) to v2 (Argon2id) + - Automatic upgrade on unlock + +5. **Cascade Deletes** + - Deleting an identity removes associated permissions and relays + - Maintains referential integrity + +### Anti-Patterns Identified + +#### 1. God Service (`StorageService`) + +**Location:** `projects/common/src/lib/services/storage/storage.service.ts` + +**Problem:** Single service handles: +- Vault lifecycle (create, unlock, delete, migrate) +- Identity CRUD (add, delete, switch) +- Permission management +- Relay configuration +- NWC wallet connections +- Cashu mint management +- Encryption/decryption orchestration + +**Symptoms:** +- 500+ lines when including bound methods +- Methods dynamically attached via functional composition +- Implicit dependencies between operations +- Difficult to test in isolation + +**DDD Violation:** Violates single responsibility; should be split into aggregate-specific repositories. + +#### 2. Anemic Domain Models + +**Location:** `projects/common/src/lib/services/storage/types.ts` + +**Problem:** All domain types are pure data containers: + +```typescript +// Current: Anemic model +interface Identity_DECRYPTED { + id: string; + nick: string; + privkey: string; + createdAt: string; +} + +// All behavior lives in external functions: +// - addIdentity() in identity.ts +// - switchIdentity() in identity.ts +// - encryptIdentity() in identity.ts +``` + +**Should Be:** +```typescript +// Rich domain model +class Identity { + private constructor( + private readonly _id: IdentityId, + private _nick: Nickname, + private readonly _keyPair: NostrKeyPair, + private readonly _createdAt: Date + ) {} + + static create(nick: string, privateKey?: string): Identity { /* ... */ } + + get publicKey(): string { return this._keyPair.publicKey; } + + sign(event: UnsignedEvent): SignedEvent { + return this._keyPair.sign(event); + } + + rename(newNick: string): void { + this._nick = Nickname.create(newNick); + } +} +``` + +#### 3. Mixed Encryption Concerns + +**Problem:** Domain operations and encryption logic are interleaved: + +```typescript +// In identity.ts +export async function addIdentity(this: StorageService, data: {...}) { + // Domain logic + const identity_decrypted: Identity_DECRYPTED = { + id: uuid(), + nick: data.nick, + privkey: data.privkeyString, + createdAt: new Date().toISOString(), + }; + + // Encryption concern mixed in + const identity_encrypted = await encryptIdentity.call(this, identity_decrypted); + + // Storage concern + await this.#browserSyncHandler.addIdentity(identity_encrypted); + this.#browserSessionHandler.addIdentity(identity_decrypted); +} +``` + +**Should Be:** Encryption as infrastructure layer, repositories handle persistence: + +```typescript +class IdentityRepository { + async save(identity: Identity): Promise { + const encrypted = this.encryptionService.encrypt(identity.toSnapshot()); + await this.syncHandler.save(encrypted); + this.sessionHandler.cache(identity); + } +} +``` + +#### 4. Weak Ubiquitous Language + +**Problem:** Type names reflect technical storage, not domain concepts: + +| Current Name | Domain Concept | +|--------------|----------------| +| `BrowserSyncData` | `EncryptedVault` | +| `BrowserSessionData` | `UnlockedVaultState` | +| `SignerMetaData` | `ExtensionSettings` | +| `Identity_DECRYPTED` | `Identity` | +| `Identity_ENCRYPTED` | `EncryptedIdentity` | + +#### 5. Implicit Aggregate Boundaries + +**Problem:** No clear aggregate roots. External code can manipulate any data: + +```typescript +// Anyone can reach into session data +const identity = this.#browserSessionHandler.getIdentity(id); +identity.nick = "changed"; // No invariant protection! +``` + +**Should Have:** Aggregate roots as single entry points with invariant protection. + +#### 6. TypeScript Union Type Issues + +**Problem:** `LockedVaultContext` uses optional fields instead of discriminated unions: + +```typescript +// Current: Confusing optional fields +type LockedVaultContext = + | { iv: string; password: string; keyBase64?: undefined } + | { iv: string; keyBase64: string; password?: undefined }; + +// Better: Discriminated union +type LockedVaultContext = + | { version: 1; iv: string; password: string } + | { version: 2; iv: string; keyBase64: string }; +``` + +--- + +## Recommended Domain Model + +### Aggregate Design + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ AGGREGATE MAP │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ Vault Aggregate (Root: Vault) │ │ +│ │ │ │ +│ │ Vault ──────┬──► Identity[] (child entities) │ │ +│ │ ├──► Permission[] (child entities) │ │ +│ │ ├──► Relay[] (child entities) │ │ +│ │ ├──► NwcConnection[] (child entities) │ │ +│ │ └──► CashuMint[] (child entities) │ │ +│ │ │ │ +│ │ Invariants: │ │ +│ │ - At most one identity can be selected │ │ +│ │ - Permissions must reference existing identities │ │ +│ │ - Relays must reference existing identities │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ ExtensionSettings Aggregate (Root: ExtensionSettings) │ │ +│ │ │ │ +│ │ ExtensionSettings ──┬──► SyncPreference │ │ +│ │ ├──► SecurityPolicy (reckless, whitelist)│ │ +│ │ ├──► Bookmark[] │ │ +│ │ └──► VaultSnapshot[] │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ ProfileCache Aggregate (Root: ProfileCache) │ │ +│ │ │ │ +│ │ ProfileCache ──► ProfileMetadata[] │ │ +│ │ │ │ +│ │ Invariants: │ │ +│ │ - Entries expire after TTL │ │ +│ │ - One entry per pubkey │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### Value Objects + +```typescript +// Strongly-typed identity +class IdentityId { + private constructor(private readonly value: string) {} + static generate(): IdentityId { return new IdentityId(uuid()); } + static from(value: string): IdentityId { return new IdentityId(value); } + equals(other: IdentityId): boolean { return this.value === other.value; } + toString(): string { return this.value; } +} + +// Self-validating nickname +class Nickname { + private constructor(private readonly value: string) {} + static create(value: string): Nickname { + if (!value || value.trim().length === 0) { + throw new InvalidNicknameError(value); + } + return new Nickname(value.trim()); + } + toString(): string { return this.value; } +} + +// Nostr key pair encapsulation +class NostrKeyPair { + private constructor( + private readonly privateKeyHex: string, + private readonly publicKeyHex: string + ) {} + + static fromPrivateKey(privkey: string): NostrKeyPair { + const hex = privkey.startsWith('nsec') + ? NostrHelper.nsecToHex(privkey) + : privkey; + const pubkey = NostrHelper.pubkeyFromPrivkey(hex); + return new NostrKeyPair(hex, pubkey); + } + + get publicKey(): string { return this.publicKeyHex; } + get npub(): string { return NostrHelper.pubkey2npub(this.publicKeyHex); } + + sign(event: UnsignedEvent): SignedEvent { + return NostrHelper.signEvent(event, this.privateKeyHex); + } + + encrypt(plaintext: string, recipientPubkey: string, version: 4 | 44): string { + return version === 4 + ? NostrHelper.nip04Encrypt(plaintext, this.privateKeyHex, recipientPubkey) + : NostrHelper.nip44Encrypt(plaintext, this.privateKeyHex, recipientPubkey); + } +} + +// Permission policy +class PermissionPolicy { + private constructor( + private readonly identityId: IdentityId, + private readonly host: string, + private readonly method: Nip07Method, + private readonly decision: 'allow' | 'deny', + private readonly kind?: number + ) {} + + static allow(identityId: IdentityId, host: string, method: Nip07Method, kind?: number): PermissionPolicy { + return new PermissionPolicy(identityId, host, method, 'allow', kind); + } + + static deny(identityId: IdentityId, host: string, method: Nip07Method, kind?: number): PermissionPolicy { + return new PermissionPolicy(identityId, host, method, 'deny', kind); + } + + matches(identityId: IdentityId, host: string, method: Nip07Method, kind?: number): boolean { + return this.identityId.equals(identityId) + && this.host === host + && this.method === method + && (this.kind === undefined || this.kind === kind); + } + + isAllowed(): boolean { return this.decision === 'allow'; } +} +``` + +### Rich Domain Entities + +```typescript +class Identity { + private readonly _id: IdentityId; + private _nickname: Nickname; + private readonly _keyPair: NostrKeyPair; + private readonly _createdAt: Date; + private _domainEvents: DomainEvent[] = []; + + private constructor( + id: IdentityId, + nickname: Nickname, + keyPair: NostrKeyPair, + createdAt: Date + ) { + this._id = id; + this._nickname = nickname; + this._keyPair = keyPair; + this._createdAt = createdAt; + } + + static create(nickname: string, privateKey?: string): Identity { + const keyPair = privateKey + ? NostrKeyPair.fromPrivateKey(privateKey) + : NostrKeyPair.generate(); + + const identity = new Identity( + IdentityId.generate(), + Nickname.create(nickname), + keyPair, + new Date() + ); + + identity._domainEvents.push(new IdentityCreated(identity._id, identity.publicKey)); + return identity; + } + + get id(): IdentityId { return this._id; } + get publicKey(): string { return this._keyPair.publicKey; } + get npub(): string { return this._keyPair.npub; } + get nickname(): string { return this._nickname.toString(); } + + rename(newNickname: string): void { + const oldNickname = this._nickname.toString(); + this._nickname = Nickname.create(newNickname); + this._domainEvents.push(new IdentityRenamed(this._id, oldNickname, newNickname)); + } + + sign(event: UnsignedEvent): SignedEvent { + return this._keyPair.sign(event); + } + + encrypt(plaintext: string, recipientPubkey: string, version: 4 | 44): string { + return this._keyPair.encrypt(plaintext, recipientPubkey, version); + } + + pullDomainEvents(): DomainEvent[] { + const events = [...this._domainEvents]; + this._domainEvents = []; + return events; + } +} +``` + +--- + +## Refactoring Roadmap + +### Phase 1: Extract Value Objects (Low Risk) + +**Goal:** Introduce type safety without changing behavior. + +1. Create `IdentityId`, `Nickname`, `NostrKeyPair` value objects +2. Use them in existing interfaces initially +3. Add validation in factory methods +4. Update helpers to use value objects + +**Files to Modify:** +- Create `projects/common/src/lib/domain/value-objects/` +- Update `projects/common/src/lib/helpers/nostr-helper.ts` + +### Phase 2: Introduce Repository Pattern (Medium Risk) + +**Goal:** Separate storage concerns from domain logic. + +1. Define repository interfaces in domain layer +2. Create `IdentityRepository`, `PermissionRepository`, etc. +3. Move encryption to `EncryptionService` infrastructure +4. Refactor `StorageService` to delegate to repositories + +**New Structure:** +``` +projects/common/src/lib/ +├── domain/ +│ ├── identity/ +│ │ ├── Identity.ts +│ │ ├── IdentityRepository.ts (interface) +│ │ └── events/ +│ ├── permission/ +│ │ ├── PermissionPolicy.ts +│ │ └── PermissionRepository.ts (interface) +│ └── vault/ +│ ├── Vault.ts +│ └── VaultRepository.ts (interface) +├── infrastructure/ +│ ├── encryption/ +│ │ └── EncryptionService.ts +│ └── persistence/ +│ ├── ChromeIdentityRepository.ts +│ └── FirefoxIdentityRepository.ts +└── application/ + ├── IdentityApplicationService.ts + └── VaultApplicationService.ts +``` + +### Phase 3: Rich Domain Model (Higher Risk) + +**Goal:** Move behavior into domain entities. + +1. Convert `Identity_DECRYPTED` interface to `Identity` class +2. Move signing logic into `Identity.sign()` +3. Move encryption decision logic into domain +4. Add domain events for state changes + +### Phase 4: Ubiquitous Language Cleanup + +**Goal:** Align code with domain language. + +| Old Name | New Name | +|----------|----------| +| `BrowserSyncData` | `EncryptedVault` | +| `BrowserSessionData` | `VaultSession` | +| `SignerMetaData` | `ExtensionSettings` | +| `StorageService` | `VaultService` (or split into multiple) | +| `addIdentity()` | `Identity.create()` + `IdentityRepository.save()` | +| `switchIdentity()` | `Vault.selectIdentity()` | + +--- + +## Implementation Priorities + +### High Priority (Security/Correctness) + +1. **Encapsulate KeyPair operations** - Private keys should never be accessed directly +2. **Enforce invariants** - Selected identity must exist, permissions must reference valid identities +3. **Clear transaction boundaries** - What gets saved together? + +### Medium Priority (Maintainability) + +1. **Split StorageService** - Into VaultService, IdentityRepository, PermissionRepository +2. **Extract EncryptionService** - Pure infrastructure concern +3. **Type-safe IDs** - Prevent mixing up identity IDs with permission IDs + +### Lower Priority (Polish) + +1. **Domain events** - For audit trail and extensibility +2. **Full ubiquitous language** - Rename all types +3. **Discriminated unions** - For vault context types + +--- + +## Testing Implications + +Current state makes testing difficult because: +- `StorageService` requires mocking 4 handlers +- Encryption is interleaved with logic +- No clear boundaries to test in isolation + +With proposed changes: +- Domain entities testable in isolation (no storage mocks) +- Repositories testable with in-memory implementations +- Clear separation enables focused unit tests + +```typescript +// Example: Testing Identity domain logic +describe('Identity', () => { + it('signs events with internal keypair', () => { + const identity = Identity.create('Test', 'nsec1...'); + const event = { kind: 1, content: 'test', /* ... */ }; + + const signed = identity.sign(event); + + expect(signed.sig).toBeDefined(); + expect(signed.pubkey).toBe(identity.publicKey); + }); + + it('prevents duplicate private keys via repository', async () => { + const repository = new InMemoryIdentityRepository(); + const existing = Identity.create('First', 'nsec1abc...'); + await repository.save(existing); + + const duplicate = Identity.create('Second', 'nsec1abc...'); + + await expect(repository.save(duplicate)) + .rejects.toThrow(DuplicateIdentityError); + }); +}); +``` + +--- + +## Conclusion + +The Plebeian Signer codebase has solid foundations but would benefit significantly from DDD tactical patterns. The recommended approach: + +1. **Start with value objects** - Low risk, immediate type safety benefits +2. **Introduce repositories gradually** - Extract one at a time, starting with Identity +3. **Defer full rich domain model** - Until repositories stabilize the architecture +4. **Update language as you go** - Rename types when touching files anyway + +The goal is not architectural purity but **maintainability, testability, and security**. DDD patterns are a means to those ends in a domain (cryptographic identity management) where correctness matters. diff --git a/projects/chrome/src/app/common/data/chrome-meta-handler.ts b/projects/chrome/src/app/common/data/chrome-meta-handler.ts index 20d1e07..8edebd7 100644 --- a/projects/chrome/src/app/common/data/chrome-meta-handler.ts +++ b/projects/chrome/src/app/common/data/chrome-meta-handler.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { SignerMetaData, SignerMetaHandler } from '@common'; +import { ExtensionSettings, SignerMetaHandler } from '@common'; export class ChromeMetaHandler extends SignerMetaHandler { async loadFullData(): Promise>> { @@ -19,7 +19,7 @@ export class ChromeMetaHandler extends SignerMetaHandler { return data; } - async saveFullData(data: SignerMetaData): Promise { + async saveFullData(data: ExtensionSettings): Promise { await chrome.storage.local.set(data); } diff --git a/projects/chrome/src/app/common/data/chrome-session-handler.ts b/projects/chrome/src/app/common/data/chrome-session-handler.ts index 493b47d..ee4245d 100644 --- a/projects/chrome/src/app/common/data/chrome-session-handler.ts +++ b/projects/chrome/src/app/common/data/chrome-session-handler.ts @@ -1,12 +1,12 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { BrowserSessionData, BrowserSessionHandler } from '@common'; +import { VaultSession, BrowserSessionHandler } from '@common'; export class ChromeSessionHandler extends BrowserSessionHandler { async loadFullData(): Promise>> { return chrome.storage.session.get(null); } - async saveFullData(data: BrowserSessionData): Promise { + async saveFullData(data: VaultSession): Promise { await chrome.storage.session.set(data); } diff --git a/projects/chrome/src/app/common/data/chrome-sync-no-handler.ts b/projects/chrome/src/app/common/data/chrome-sync-no-handler.ts index 58316fa..ad0c6ef 100644 --- a/projects/chrome/src/app/common/data/chrome-sync-no-handler.ts +++ b/projects/chrome/src/app/common/data/chrome-sync-no-handler.ts @@ -1,12 +1,12 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { - BrowserSyncData, + EncryptedVault, BrowserSyncHandler, - CashuMint_ENCRYPTED, - Identity_ENCRYPTED, - NwcConnection_ENCRYPTED, - Permission_ENCRYPTED, - Relay_ENCRYPTED, + StoredCashuMint, + StoredIdentity, + StoredNwcConnection, + StoredPermission, + StoredRelay, } from '@common'; /** @@ -26,20 +26,20 @@ export class ChromeSyncNoHandler extends BrowserSyncHandler { return data; } - async saveAndSetFullData(data: BrowserSyncData): Promise { + async saveAndSetFullData(data: EncryptedVault): Promise { await chrome.storage.local.set(data); this.setFullData(data); } async saveAndSetPartialData_Permissions(data: { - permissions: Permission_ENCRYPTED[]; + permissions: StoredPermission[]; }): Promise { await chrome.storage.local.set(data); this.setPartialData_Permissions(data); } async saveAndSetPartialData_Identities(data: { - identities: Identity_ENCRYPTED[]; + identities: StoredIdentity[]; }): Promise { await chrome.storage.local.set(data); this.setPartialData_Identities(data); @@ -53,21 +53,21 @@ export class ChromeSyncNoHandler extends BrowserSyncHandler { } async saveAndSetPartialData_Relays(data: { - relays: Relay_ENCRYPTED[]; + relays: StoredRelay[]; }): Promise { await chrome.storage.local.set(data); this.setPartialData_Relays(data); } async saveAndSetPartialData_NwcConnections(data: { - nwcConnections: NwcConnection_ENCRYPTED[]; + nwcConnections: StoredNwcConnection[]; }): Promise { await chrome.storage.local.set(data); this.setPartialData_NwcConnections(data); } async saveAndSetPartialData_CashuMints(data: { - cashuMints: CashuMint_ENCRYPTED[]; + cashuMints: StoredCashuMint[]; }): Promise { await chrome.storage.local.set(data); this.setPartialData_CashuMints(data); diff --git a/projects/chrome/src/app/common/data/chrome-sync-yes-handler.ts b/projects/chrome/src/app/common/data/chrome-sync-yes-handler.ts index dc27624..0598ff7 100644 --- a/projects/chrome/src/app/common/data/chrome-sync-yes-handler.ts +++ b/projects/chrome/src/app/common/data/chrome-sync-yes-handler.ts @@ -1,12 +1,12 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { - BrowserSyncData, - CashuMint_ENCRYPTED, - Identity_ENCRYPTED, - NwcConnection_ENCRYPTED, - Permission_ENCRYPTED, + EncryptedVault, + StoredCashuMint, + StoredIdentity, + StoredNwcConnection, + StoredPermission, BrowserSyncHandler, - Relay_ENCRYPTED, + StoredRelay, } from '@common'; /** @@ -18,20 +18,20 @@ export class ChromeSyncYesHandler extends BrowserSyncHandler { return await chrome.storage.sync.get(null); } - async saveAndSetFullData(data: BrowserSyncData): Promise { + async saveAndSetFullData(data: EncryptedVault): Promise { await chrome.storage.sync.set(data); this.setFullData(data); } async saveAndSetPartialData_Permissions(data: { - permissions: Permission_ENCRYPTED[]; + permissions: StoredPermission[]; }): Promise { await chrome.storage.sync.set(data); this.setPartialData_Permissions(data); } async saveAndSetPartialData_Identities(data: { - identities: Identity_ENCRYPTED[]; + identities: StoredIdentity[]; }): Promise { await chrome.storage.sync.set(data); this.setPartialData_Identities(data); @@ -45,21 +45,21 @@ export class ChromeSyncYesHandler extends BrowserSyncHandler { } async saveAndSetPartialData_Relays(data: { - relays: Relay_ENCRYPTED[]; + relays: StoredRelay[]; }): Promise { await chrome.storage.sync.set(data); this.setPartialData_Relays(data); } async saveAndSetPartialData_NwcConnections(data: { - nwcConnections: NwcConnection_ENCRYPTED[]; + nwcConnections: StoredNwcConnection[]; }): Promise { await chrome.storage.sync.set(data); this.setPartialData_NwcConnections(data); } async saveAndSetPartialData_CashuMints(data: { - cashuMints: CashuMint_ENCRYPTED[]; + cashuMints: StoredCashuMint[]; }): Promise { await chrome.storage.sync.set(data); this.setPartialData_CashuMints(data); diff --git a/projects/chrome/src/app/components/edit-identity/permissions/permissions.component.html b/projects/chrome/src/app/components/edit-identity/permissions/permissions.component.html index 5a303a6..7b700b9 100644 --- a/projects/chrome/src/app/components/edit-identity/permissions/permissions.component.html +++ b/projects/chrome/src/app/components/edit-identity/permissions/permissions.component.html @@ -27,7 +27,7 @@ > {{ permission.method }} @if(typeof permission.kind !== 'undefined') { - (kind {{ permission.kind }}) + (kind {{ permission.kind }}) }
{ fixture = TestBed.createComponent(IconButtonComponent); component = fixture.componentInstance; + component.icon = 'settings'; // Required input fixture.detectChanges(); }); diff --git a/projects/common/src/lib/components/pubkey/pubkey.component.spec.ts b/projects/common/src/lib/components/pubkey/pubkey.component.spec.ts index 9c0423a..89cce19 100644 --- a/projects/common/src/lib/components/pubkey/pubkey.component.spec.ts +++ b/projects/common/src/lib/components/pubkey/pubkey.component.spec.ts @@ -14,6 +14,8 @@ describe('PubkeyComponent', () => { fixture = TestBed.createComponent(PubkeyComponent); component = fixture.componentInstance; + // Valid test pubkey (64 hex chars) + component.value = 'a'.repeat(64); fixture.detectChanges(); }); diff --git a/projects/common/src/lib/domain/entities/identity.spec.ts b/projects/common/src/lib/domain/entities/identity.spec.ts new file mode 100644 index 0000000..26a4f7d --- /dev/null +++ b/projects/common/src/lib/domain/entities/identity.spec.ts @@ -0,0 +1,200 @@ +import { Identity, UnsignedEvent, SignedEvent, SigningFunction } from './identity'; +import { IdentityCreated, IdentityRenamed, IdentitySigned } from '../events'; + +describe('Identity Entity', () => { + const TEST_PRIVATE_KEY = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + + describe('create', () => { + it('should create identity with generated keypair when no private key provided', () => { + const identity = Identity.create('Alice'); + + expect(identity.nickname).toEqual('Alice'); + expect(identity.publicKey).toBeTruthy(); + expect(identity.publicKey.length).toBe(64); + }); + + it('should create identity with provided private key', () => { + const identity = Identity.create('Bob', TEST_PRIVATE_KEY); + + expect(identity.nickname).toEqual('Bob'); + expect(identity.publicKey).toBeTruthy(); + }); + + it('should raise IdentityCreated event', () => { + const identity = Identity.create('Charlie'); + const events = identity.pullDomainEvents(); + + expect(events.length).toBe(1); + expect(events[0]).toBeInstanceOf(IdentityCreated); + + const createdEvent = events[0] as IdentityCreated; + expect(createdEvent.identityId).toEqual(identity.id.toString()); + expect(createdEvent.publicKey).toEqual(identity.publicKey); + expect(createdEvent.nickname).toEqual('Charlie'); + }); + + it('should set createdAt timestamp', () => { + const before = new Date(); + const identity = Identity.create('Dana'); + const after = new Date(); + + expect(identity.createdAt.getTime()).toBeGreaterThanOrEqual(before.getTime()); + expect(identity.createdAt.getTime()).toBeLessThanOrEqual(after.getTime()); + }); + }); + + describe('fromSnapshot', () => { + it('should reconstruct identity from snapshot', () => { + const original = Identity.create('Eve', TEST_PRIVATE_KEY); + original.pullDomainEvents(); // Clear creation event + + const snapshot = original.toSnapshot(); + const restored = Identity.fromSnapshot(snapshot); + + expect(restored.id.toString()).toEqual(original.id.toString()); + expect(restored.nickname).toEqual('Eve'); + expect(restored.publicKey).toEqual(original.publicKey); + }); + + it('should not raise events when loading from snapshot', () => { + const original = Identity.create('Frank'); + const snapshot = original.toSnapshot(); + + const restored = Identity.fromSnapshot(snapshot); + const events = restored.pullDomainEvents(); + + expect(events.length).toBe(0); + }); + }); + + describe('rename', () => { + it('should update nickname', () => { + const identity = Identity.create('OldName'); + identity.pullDomainEvents(); // Clear creation event + + identity.rename('NewName'); + + expect(identity.nickname).toEqual('NewName'); + }); + + it('should raise IdentityRenamed event', () => { + const identity = Identity.create('OldName'); + identity.pullDomainEvents(); // Clear creation event + + identity.rename('NewName'); + const events = identity.pullDomainEvents(); + + expect(events.length).toBe(1); + expect(events[0]).toBeInstanceOf(IdentityRenamed); + + const renamedEvent = events[0] as IdentityRenamed; + expect(renamedEvent.identityId).toEqual(identity.id.toString()); + expect(renamedEvent.oldNickname).toEqual('OldName'); + expect(renamedEvent.newNickname).toEqual('NewName'); + }); + }); + + describe('sign', () => { + it('should call signing function with event and return signed event', () => { + const identity = Identity.create('Signer', TEST_PRIVATE_KEY); + identity.pullDomainEvents(); + + const unsignedEvent: UnsignedEvent = { + kind: 1, + created_at: Math.floor(Date.now() / 1000), + tags: [], + content: 'Hello, Nostr!', + }; + + const mockSignFn: SigningFunction = (event, privateKeyBytes) => { + expect(privateKeyBytes).toBeInstanceOf(Uint8Array); + expect(privateKeyBytes.length).toBe(32); + + return { + ...event, + id: 'mock-event-id', + pubkey: identity.publicKey, + sig: 'mock-signature', + } as SignedEvent; + }; + + const signedEvent = identity.sign(unsignedEvent, mockSignFn); + + expect(signedEvent.id).toEqual('mock-event-id'); + expect(signedEvent.pubkey).toEqual(identity.publicKey); + expect(signedEvent.sig).toEqual('mock-signature'); + }); + + it('should raise IdentitySigned event', () => { + const identity = Identity.create('Signer', TEST_PRIVATE_KEY); + identity.pullDomainEvents(); + + const unsignedEvent: UnsignedEvent = { + kind: 1, + created_at: Math.floor(Date.now() / 1000), + tags: [], + content: 'Test', + }; + + const mockSignFn: SigningFunction = (event) => ({ + ...event, + id: 'signed-event-id', + pubkey: identity.publicKey, + sig: 'sig', + } as SignedEvent); + + identity.sign(unsignedEvent, mockSignFn); + const events = identity.pullDomainEvents(); + + expect(events.length).toBe(1); + expect(events[0]).toBeInstanceOf(IdentitySigned); + + const signedEvt = events[0] as IdentitySigned; + expect(signedEvt.identityId).toEqual(identity.id.toString()); + expect(signedEvt.eventKind).toBe(1); + expect(signedEvt.signedEventId).toEqual('signed-event-id'); + }); + }); + + describe('toSnapshot', () => { + it('should create complete snapshot for storage', () => { + const identity = Identity.create('Snapshot Test', TEST_PRIVATE_KEY); + const snapshot = identity.toSnapshot(); + + expect(snapshot.id).toEqual(identity.id.toString()); + expect(snapshot.nick).toEqual('Snapshot Test'); + expect(snapshot.privkey).toBeTruthy(); + expect(snapshot.createdAt).toBeTruthy(); + }); + }); + + describe('npub', () => { + it('should return bech32 encoded public key', () => { + const identity = Identity.create('NpubTest'); + + expect(identity.npub).toMatch(/^npub1[a-z0-9]+$/); + }); + }); + + describe('pullDomainEvents', () => { + it('should clear events after pulling', () => { + const identity = Identity.create('Test'); + + const firstPull = identity.pullDomainEvents(); + const secondPull = identity.pullDomainEvents(); + + expect(firstPull.length).toBe(1); + expect(secondPull.length).toBe(0); + }); + + it('should accumulate multiple events', () => { + const identity = Identity.create('Multi'); + identity.rename('Name1'); + identity.rename('Name2'); + + const events = identity.pullDomainEvents(); + + expect(events.length).toBe(3); // Created + 2 renames + }); + }); +}); diff --git a/projects/common/src/lib/domain/entities/identity.ts b/projects/common/src/lib/domain/entities/identity.ts new file mode 100644 index 0000000..e7691b9 --- /dev/null +++ b/projects/common/src/lib/domain/entities/identity.ts @@ -0,0 +1,305 @@ +import { AggregateRoot } from '../events/domain-event'; +import { IdentityCreated, IdentityRenamed, IdentitySigned } from '../events/identity-events'; +import { + IdentityId, + Nickname, + NostrKeyPair, +} from '../value-objects'; +import type { IdentitySnapshot } from '../repositories/identity-repository'; + +/** + * Represents an unsigned Nostr event template. + * This is what gets passed to the sign method. + */ +export interface UnsignedEvent { + kind: number; + created_at: number; + tags: string[][]; + content: string; +} + +/** + * Represents a signed Nostr event. + */ +export interface SignedEvent extends UnsignedEvent { + id: string; + pubkey: string; + sig: string; +} + +/** + * Signing function type - injected to avoid coupling to nostr-tools. + */ +export type SigningFunction = (event: UnsignedEvent, privateKeyBytes: Uint8Array) => SignedEvent; + +/** + * Encryption function types for NIP-04 and NIP-44. + */ +export type EncryptFunction = ( + privateKeyBytes: Uint8Array, + peerPubkey: string, + plaintext: string +) => Promise; + +export type DecryptFunction = ( + privateKeyBytes: Uint8Array, + peerPubkey: string, + ciphertext: string +) => Promise; + +/** + * Identity entity - represents a Nostr identity with its keypair. + * + * This is an aggregate root that encapsulates all operations + * related to a single Nostr identity. + */ +export class Identity extends AggregateRoot { + private readonly _id: IdentityId; + private _nickname: Nickname; + private readonly _keyPair: NostrKeyPair; + private readonly _createdAt: Date; + + private constructor( + id: IdentityId, + nickname: Nickname, + keyPair: NostrKeyPair, + createdAt: Date + ) { + super(); + this._id = id; + this._nickname = nickname; + this._keyPair = keyPair; + this._createdAt = createdAt; + } + + // ───────────────────────────────────────────────────────────────────────── + // Factory Methods + // ───────────────────────────────────────────────────────────────────────── + + /** + * Create a new identity with an optional private key. + * If no private key is provided, a new one will be generated. + * + * @param nickname - User-friendly name for this identity + * @param privateKey - Optional private key (hex or nsec format) + * @throws InvalidNicknameError if nickname is invalid + * @throws InvalidNostrKeyError if private key is invalid + */ + static create(nickname: string, privateKey?: string): Identity { + const keyPair = privateKey + ? NostrKeyPair.fromPrivateKey(privateKey) + : NostrKeyPair.generate(); + + const identity = new Identity( + IdentityId.generate(), + Nickname.create(nickname), + keyPair, + new Date() + ); + + identity.addDomainEvent( + new IdentityCreated( + identity._id.value, + identity.publicKey, + identity.nickname + ) + ); + + return identity; + } + + /** + * Reconstitute an identity from storage. + * This bypasses validation since data comes from trusted storage. + */ + static fromSnapshot(snapshot: IdentitySnapshot): Identity { + return new Identity( + IdentityId.from(snapshot.id), + Nickname.fromStorage(snapshot.nick), + NostrKeyPair.fromStorage(snapshot.privkey), + new Date(snapshot.createdAt) + ); + } + + // ───────────────────────────────────────────────────────────────────────── + // Getters (Read-only access to state) + // ───────────────────────────────────────────────────────────────────────── + + get id(): IdentityId { + return this._id; + } + + get nickname(): string { + return this._nickname.value; + } + + get publicKey(): string { + return this._keyPair.publicKeyHex; + } + + get npub(): string { + return this._keyPair.npub; + } + + get nsec(): string { + return this._keyPair.nsec; + } + + get createdAt(): Date { + return this._createdAt; + } + + // ───────────────────────────────────────────────────────────────────────── + // Behavior Methods + // ───────────────────────────────────────────────────────────────────────── + + /** + * Rename this identity. + * + * @param newNickname - The new nickname + * @throws InvalidNicknameError if nickname is invalid + */ + rename(newNickname: string): void { + const oldNickname = this._nickname.value; + this._nickname = Nickname.create(newNickname); + + this.addDomainEvent( + new IdentityRenamed(this._id.value, oldNickname, newNickname) + ); + } + + /** + * Sign a Nostr event with this identity's private key. + * + * @param event - The unsigned event template + * @param signFn - The signing function (injected to avoid coupling) + * @returns The signed event with id, pubkey, and sig + */ + sign(event: UnsignedEvent, signFn: SigningFunction): SignedEvent { + const signedEvent = signFn(event, this._keyPair.getPrivateKeyBytes()); + + this.addDomainEvent( + new IdentitySigned(this._id.value, event.kind, signedEvent.id) + ); + + return signedEvent; + } + + /** + * Encrypt a message using NIP-04 encryption. + * + * @param plaintext - The message to encrypt + * @param recipientPubkey - The recipient's public key (hex) + * @param encryptFn - The NIP-04 encryption function + */ + async encryptNip04( + plaintext: string, + recipientPubkey: string, + encryptFn: EncryptFunction + ): Promise { + return encryptFn( + this._keyPair.getPrivateKeyBytes(), + recipientPubkey, + plaintext + ); + } + + /** + * Decrypt a message using NIP-04 decryption. + * + * @param ciphertext - The encrypted message + * @param senderPubkey - The sender's public key (hex) + * @param decryptFn - The NIP-04 decryption function + */ + async decryptNip04( + ciphertext: string, + senderPubkey: string, + decryptFn: DecryptFunction + ): Promise { + return decryptFn( + this._keyPair.getPrivateKeyBytes(), + senderPubkey, + ciphertext + ); + } + + /** + * Encrypt a message using NIP-44 encryption. + * + * @param plaintext - The message to encrypt + * @param recipientPubkey - The recipient's public key (hex) + * @param encryptFn - The NIP-44 encryption function + */ + async encryptNip44( + plaintext: string, + recipientPubkey: string, + encryptFn: EncryptFunction + ): Promise { + return encryptFn( + this._keyPair.getPrivateKeyBytes(), + recipientPubkey, + plaintext + ); + } + + /** + * Decrypt a message using NIP-44 decryption. + * + * @param ciphertext - The encrypted message + * @param senderPubkey - The sender's public key (hex) + * @param decryptFn - The NIP-44 decryption function + */ + async decryptNip44( + ciphertext: string, + senderPubkey: string, + decryptFn: DecryptFunction + ): Promise { + return decryptFn( + this._keyPair.getPrivateKeyBytes(), + senderPubkey, + ciphertext + ); + } + + /** + * Check if this identity has the same private key as another. + * Used for duplicate detection. + */ + hasSameKeyAs(other: Identity): boolean { + return this._keyPair.hasSamePublicKey(other._keyPair); + } + + /** + * Check if this identity matches a given public key. + */ + matchesPublicKey(publicKey: string): boolean { + return this._keyPair.matchesPublicKey(publicKey); + } + + // ───────────────────────────────────────────────────────────────────────── + // Persistence + // ───────────────────────────────────────────────────────────────────────── + + /** + * Convert to a snapshot for persistence. + */ + toSnapshot(): IdentitySnapshot { + return { + id: this._id.value, + nick: this._nickname.value, + privkey: this._keyPair.toStorageHex(), + createdAt: this._createdAt.toISOString(), + }; + } + + // ───────────────────────────────────────────────────────────────────────── + // Equality + // ───────────────────────────────────────────────────────────────────────── + + /** + * Check equality based on identity ID. + */ + equals(other: Identity): boolean { + return this._id.equals(other._id); + } +} diff --git a/projects/common/src/lib/domain/entities/index.ts b/projects/common/src/lib/domain/entities/index.ts new file mode 100644 index 0000000..6e50178 --- /dev/null +++ b/projects/common/src/lib/domain/entities/index.ts @@ -0,0 +1,21 @@ +export { + Identity, +} from './identity'; +export type { + UnsignedEvent, + SignedEvent, + SigningFunction, + EncryptFunction, + DecryptFunction, +} from './identity'; + +export { + Permission, + PermissionChecker, +} from './permission'; + +export { + Relay, + InvalidRelayUrlError, + toNip65RelayList, +} from './relay'; diff --git a/projects/common/src/lib/domain/entities/permission.spec.ts b/projects/common/src/lib/domain/entities/permission.spec.ts new file mode 100644 index 0000000..d23b19c --- /dev/null +++ b/projects/common/src/lib/domain/entities/permission.spec.ts @@ -0,0 +1,175 @@ +import { Permission, PermissionChecker } from './permission'; +import { IdentityId } from '../value-objects'; + +describe('Permission Entity', () => { + const testIdentityId = IdentityId.from('identity-1'); + const testHost = 'example.com'; + const testMethod = 'signEvent'; + + describe('allow', () => { + it('should create an allow permission', () => { + const permission = Permission.allow(testIdentityId, testHost, testMethod); + + expect(permission.isAllowed()).toBe(true); + }); + + it('should create permission with kind for signEvent', () => { + const permission = Permission.allow(testIdentityId, testHost, testMethod, 1); + + expect(permission.isAllowed()).toBe(true); + }); + }); + + describe('deny', () => { + it('should create a deny permission', () => { + const permission = Permission.deny(testIdentityId, testHost, testMethod); + + expect(permission.isAllowed()).toBe(false); + }); + }); + + describe('matches', () => { + it('should match when all parameters are the same', () => { + const permission = Permission.allow(testIdentityId, testHost, testMethod); + + expect(permission.matches(testIdentityId, testHost, testMethod)).toBe(true); + }); + + it('should not match when identity differs', () => { + const permission = Permission.allow(testIdentityId, testHost, testMethod); + const differentIdentity = IdentityId.from('identity-2'); + + expect(permission.matches(differentIdentity, testHost, testMethod)).toBe(false); + }); + + it('should not match when host differs', () => { + const permission = Permission.allow(testIdentityId, testHost, testMethod); + + expect(permission.matches(testIdentityId, 'other.com', testMethod)).toBe(false); + }); + + it('should not match when method differs', () => { + const permission = Permission.allow(testIdentityId, testHost, testMethod); + + expect(permission.matches(testIdentityId, testHost, 'getPublicKey')).toBe(false); + }); + + it('should match any kind when permission has no kind specified', () => { + const permission = Permission.allow(testIdentityId, testHost, testMethod); + + expect(permission.matches(testIdentityId, testHost, testMethod, 1)).toBe(true); + expect(permission.matches(testIdentityId, testHost, testMethod, 30023)).toBe(true); + }); + + it('should only match specific kind when permission has kind', () => { + const permission = Permission.allow(testIdentityId, testHost, testMethod, 1); + + expect(permission.matches(testIdentityId, testHost, testMethod, 1)).toBe(true); + expect(permission.matches(testIdentityId, testHost, testMethod, 30023)).toBe(false); + }); + }); + + describe('fromSnapshot', () => { + it('should reconstruct permission from snapshot', () => { + const original = Permission.allow(testIdentityId, testHost, testMethod, 1); + const snapshot = original.toSnapshot(); + + const restored = Permission.fromSnapshot(snapshot); + + expect(restored.isAllowed()).toBe(true); + expect(restored.matches(testIdentityId, testHost, testMethod, 1)).toBe(true); + }); + }); + + describe('toSnapshot', () => { + it('should create valid snapshot', () => { + const permission = Permission.allow(testIdentityId, testHost, testMethod, 1); + const snapshot = permission.toSnapshot(); + + expect(snapshot.identityId).toEqual(testIdentityId.toString()); + expect(snapshot.host).toEqual(testHost); + expect(snapshot.method).toEqual(testMethod); + expect(snapshot.methodPolicy).toEqual('allow'); + expect(snapshot.kind).toBe(1); + }); + }); +}); + +describe('PermissionChecker', () => { + const identity1 = IdentityId.from('identity-1'); + const identity2 = IdentityId.from('identity-2'); + + describe('check', () => { + it('should return true for allowed permission', () => { + const permissions = [ + Permission.allow(identity1, 'example.com', 'signEvent'), + ]; + const checker = new PermissionChecker(permissions); + + expect(checker.check(identity1, 'example.com', 'signEvent')).toBe(true); + }); + + it('should return false for denied permission', () => { + const permissions = [ + Permission.deny(identity1, 'example.com', 'signEvent'), + ]; + const checker = new PermissionChecker(permissions); + + expect(checker.check(identity1, 'example.com', 'signEvent')).toBe(false); + }); + + it('should return undefined when no matching permission exists', () => { + const permissions = [ + Permission.allow(identity1, 'example.com', 'signEvent'), + ]; + const checker = new PermissionChecker(permissions); + + expect(checker.check(identity2, 'example.com', 'signEvent')).toBeUndefined(); + }); + + it('should check kind-specific permissions first', () => { + const permissions = [ + Permission.deny(identity1, 'example.com', 'signEvent', 1), // Deny kind 1 + Permission.allow(identity1, 'example.com', 'signEvent'), // Allow all others + ]; + const checker = new PermissionChecker(permissions); + + expect(checker.check(identity1, 'example.com', 'signEvent', 1)).toBe(false); + expect(checker.check(identity1, 'example.com', 'signEvent', 30023)).toBe(true); + }); + + it('should handle multiple identities', () => { + const permissions = [ + Permission.allow(identity1, 'example.com', 'signEvent'), + Permission.deny(identity2, 'example.com', 'signEvent'), + ]; + const checker = new PermissionChecker(permissions); + + expect(checker.check(identity1, 'example.com', 'signEvent')).toBe(true); + expect(checker.check(identity2, 'example.com', 'signEvent')).toBe(false); + }); + + it('should handle multiple hosts', () => { + const permissions = [ + Permission.allow(identity1, 'allowed.com', 'signEvent'), + Permission.deny(identity1, 'denied.com', 'signEvent'), + ]; + const checker = new PermissionChecker(permissions); + + expect(checker.check(identity1, 'allowed.com', 'signEvent')).toBe(true); + expect(checker.check(identity1, 'denied.com', 'signEvent')).toBe(false); + expect(checker.check(identity1, 'unknown.com', 'signEvent')).toBeUndefined(); + }); + + it('should handle multiple methods', () => { + const permissions = [ + Permission.allow(identity1, 'example.com', 'getPublicKey'), + Permission.deny(identity1, 'example.com', 'signEvent'), + ]; + const checker = new PermissionChecker(permissions); + + expect(checker.check(identity1, 'example.com', 'getPublicKey')).toBe(true); + expect(checker.check(identity1, 'example.com', 'signEvent')).toBe(false); + }); + }); +}); diff --git a/projects/common/src/lib/domain/entities/permission.ts b/projects/common/src/lib/domain/entities/permission.ts new file mode 100644 index 0000000..e345f2f --- /dev/null +++ b/projects/common/src/lib/domain/entities/permission.ts @@ -0,0 +1,332 @@ +import { IdentityId, PermissionId } from '../value-objects'; +import type { + PermissionSnapshot, + ExtensionMethod, + PermissionPolicy, +} from '../repositories/permission-repository'; + +/** + * Permission entity - represents an authorization decision for + * a specific identity, host, and method combination. + * + * Permissions are immutable once created - to change a permission, + * delete it and create a new one. + */ +export class Permission { + private readonly _id: PermissionId; + private readonly _identityId: IdentityId; + private readonly _host: string; + private readonly _method: ExtensionMethod; + private readonly _policy: PermissionPolicy; + private readonly _kind?: number; + + private constructor( + id: PermissionId, + identityId: IdentityId, + host: string, + method: ExtensionMethod, + policy: PermissionPolicy, + kind?: number + ) { + this._id = id; + this._identityId = identityId; + this._host = host; + this._method = method; + this._policy = policy; + this._kind = kind; + } + + // ───────────────────────────────────────────────────────────────────────── + // Factory Methods + // ───────────────────────────────────────────────────────────────────────── + + /** + * Create an "allow" permission. + */ + static allow( + identityId: IdentityId, + host: string, + method: ExtensionMethod, + kind?: number + ): Permission { + return new Permission( + PermissionId.generate(), + identityId, + Permission.normalizeHost(host), + method, + 'allow', + kind + ); + } + + /** + * Create a "deny" permission. + */ + static deny( + identityId: IdentityId, + host: string, + method: ExtensionMethod, + kind?: number + ): Permission { + return new Permission( + PermissionId.generate(), + identityId, + Permission.normalizeHost(host), + method, + 'deny', + kind + ); + } + + /** + * Create a permission with explicit policy. + */ + static create( + identityId: IdentityId, + host: string, + method: ExtensionMethod, + policy: PermissionPolicy, + kind?: number + ): Permission { + return new Permission( + PermissionId.generate(), + identityId, + Permission.normalizeHost(host), + method, + policy, + kind + ); + } + + /** + * Reconstitute a permission from storage. + */ + static fromSnapshot(snapshot: PermissionSnapshot): Permission { + return new Permission( + PermissionId.from(snapshot.id), + IdentityId.from(snapshot.identityId), + snapshot.host, + snapshot.method, + snapshot.methodPolicy, + snapshot.kind + ); + } + + // ───────────────────────────────────────────────────────────────────────── + // Getters + // ───────────────────────────────────────────────────────────────────────── + + get id(): PermissionId { + return this._id; + } + + get identityId(): IdentityId { + return this._identityId; + } + + get host(): string { + return this._host; + } + + get method(): ExtensionMethod { + return this._method; + } + + get policy(): PermissionPolicy { + return this._policy; + } + + get kind(): number | undefined { + return this._kind; + } + + // ───────────────────────────────────────────────────────────────────────── + // Behavior + // ───────────────────────────────────────────────────────────────────────── + + /** + * Check if this permission allows the action. + */ + isAllowed(): boolean { + return this._policy === 'allow'; + } + + /** + * Check if this permission denies the action. + */ + isDenied(): boolean { + return this._policy === 'deny'; + } + + /** + * Check if this permission matches the given criteria. + * For signEvent with kind specified, also checks the kind. + */ + matches( + identityId: IdentityId, + host: string, + method: ExtensionMethod, + kind?: number + ): boolean { + if (!this._identityId.equals(identityId)) { + return false; + } + + if (this._host !== Permission.normalizeHost(host)) { + return false; + } + + if (this._method !== method) { + return false; + } + + // For signEvent, handle kind matching + if (method === 'signEvent') { + // If this permission has no kind, it matches all kinds + if (this._kind === undefined) { + return true; + } + // If checking a specific kind, must match exactly + return this._kind === kind; + } + + return true; + } + + /** + * Check if this permission applies to a specific event kind. + * Only relevant for signEvent method. + */ + appliesToKind(kind: number): boolean { + if (this._method !== 'signEvent') { + return false; + } + // No kind restriction means applies to all + if (this._kind === undefined) { + return true; + } + return this._kind === kind; + } + + /** + * Check if this is a blanket permission (no kind restriction). + */ + isBlanketPermission(): boolean { + return this._method === 'signEvent' && this._kind === undefined; + } + + // ───────────────────────────────────────────────────────────────────────── + // Persistence + // ───────────────────────────────────────────────────────────────────────── + + /** + * Convert to a snapshot for persistence. + */ + toSnapshot(): PermissionSnapshot { + const snapshot: PermissionSnapshot = { + id: this._id.value, + identityId: this._identityId.value, + host: this._host, + method: this._method, + methodPolicy: this._policy, + }; + + if (this._kind !== undefined) { + snapshot.kind = this._kind; + } + + return snapshot; + } + + // ───────────────────────────────────────────────────────────────────────── + // Equality + // ───────────────────────────────────────────────────────────────────────── + + /** + * Check equality based on permission ID. + */ + equals(other: Permission): boolean { + return this._id.equals(other._id); + } + + // ───────────────────────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────────────────────── + + private static normalizeHost(host: string): string { + return host.toLowerCase().trim(); + } +} + +/** + * Permission checker - evaluates permissions for a request. + * This encapsulates the permission checking logic. + */ +export class PermissionChecker { + constructor(private readonly permissions: Permission[]) {} + + /** + * Check if an action is allowed. + * + * @returns true if allowed, false if denied, undefined if no matching permission + */ + check( + identityId: IdentityId, + host: string, + method: ExtensionMethod, + kind?: number + ): boolean | undefined { + const matching = this.permissions.filter((p) => + p.matches(identityId, host, method, kind) + ); + + if (matching.length === 0) { + return undefined; + } + + // For signEvent with kind, check specific rules + // Kind-specific rules take priority over blanket rules + if (method === 'signEvent' && kind !== undefined) { + // Check for specific kind deny first (takes priority) + if (matching.some((p) => p.kind === kind && p.isDenied())) { + return false; + } + + // Check for specific kind allow + if (matching.some((p) => p.kind === kind && p.isAllowed())) { + return true; + } + + // Fall back to blanket allow (no kind restriction) + if (matching.some((p) => p.isBlanketPermission() && p.isAllowed())) { + return true; + } + + // Fall back to blanket deny + if (matching.some((p) => p.isBlanketPermission() && p.isDenied())) { + return false; + } + + // No specific rule found + return undefined; + } + + // For other methods, all matching permissions must allow + return matching.every((p) => p.isAllowed()); + } + + /** + * Get all permissions for a specific identity. + */ + forIdentity(identityId: IdentityId): Permission[] { + return this.permissions.filter((p) => p.identityId.equals(identityId)); + } + + /** + * Get all permissions for a specific host. + */ + forHost(host: string): Permission[] { + const normalizedHost = host.toLowerCase().trim(); + return this.permissions.filter((p) => p.host === normalizedHost); + } +} diff --git a/projects/common/src/lib/domain/entities/relay.spec.ts b/projects/common/src/lib/domain/entities/relay.spec.ts new file mode 100644 index 0000000..45775e2 --- /dev/null +++ b/projects/common/src/lib/domain/entities/relay.spec.ts @@ -0,0 +1,155 @@ +import { Relay, InvalidRelayUrlError, toNip65RelayList } from './relay'; +import { IdentityId } from '../value-objects'; + +describe('Relay Entity', () => { + const testIdentityId = IdentityId.from('identity-1'); + const validUrl = 'wss://relay.example.com'; + + describe('create', () => { + it('should create relay with default read/write permissions', () => { + const relay = Relay.create(testIdentityId, validUrl); + + expect(relay.url).toEqual(validUrl); + expect(relay.read).toBe(true); + expect(relay.write).toBe(true); + }); + + it('should create relay with specified permissions', () => { + const relay = Relay.create(testIdentityId, validUrl, true, false); + + expect(relay.read).toBe(true); + expect(relay.write).toBe(false); + }); + + it('should create relay with read-only permissions', () => { + const relay = Relay.create(testIdentityId, validUrl, true, false); + + expect(relay.read).toBe(true); + expect(relay.write).toBe(false); + }); + + it('should create relay with write-only permissions', () => { + const relay = Relay.create(testIdentityId, validUrl, false, true); + + expect(relay.read).toBe(false); + expect(relay.write).toBe(true); + }); + + it('should throw InvalidRelayUrlError for invalid URL', () => { + expect(() => Relay.create(testIdentityId, 'not-a-url')).toThrowError(InvalidRelayUrlError); + }); + + it('should throw InvalidRelayUrlError for http URL', () => { + expect(() => Relay.create(testIdentityId, 'http://relay.example.com')).toThrowError(InvalidRelayUrlError); + }); + + it('should accept wss:// URL', () => { + expect(() => Relay.create(testIdentityId, 'wss://relay.example.com')).not.toThrow(); + }); + + it('should accept ws:// URL (for local development)', () => { + expect(() => Relay.create(testIdentityId, 'ws://localhost:8080')).not.toThrow(); + }); + }); + + describe('updateUrl', () => { + it('should update URL to valid new URL', () => { + const relay = Relay.create(testIdentityId, validUrl); + + relay.updateUrl('wss://new-relay.example.com'); + + expect(relay.url).toEqual('wss://new-relay.example.com'); + }); + + it('should throw InvalidRelayUrlError for invalid new URL', () => { + const relay = Relay.create(testIdentityId, validUrl); + + expect(() => relay.updateUrl('not-a-url')).toThrowError(InvalidRelayUrlError); + }); + }); + + describe('read permission toggling', () => { + it('should enable read', () => { + const relay = Relay.create(testIdentityId, validUrl, false, false); + + relay.enableRead(); + + expect(relay.read).toBe(true); + }); + + it('should disable read', () => { + const relay = Relay.create(testIdentityId, validUrl, true, true); + + relay.disableRead(); + + expect(relay.read).toBe(false); + }); + }); + + describe('write permission toggling', () => { + it('should enable write', () => { + const relay = Relay.create(testIdentityId, validUrl, false, false); + + relay.enableWrite(); + + expect(relay.write).toBe(true); + }); + + it('should disable write', () => { + const relay = Relay.create(testIdentityId, validUrl, true, true); + + relay.disableWrite(); + + expect(relay.write).toBe(false); + }); + }); + + describe('fromSnapshot', () => { + it('should reconstruct relay from snapshot', () => { + const original = Relay.create(testIdentityId, validUrl, true, false); + const snapshot = original.toSnapshot(); + + const restored = Relay.fromSnapshot(snapshot); + + expect(restored.url).toEqual(validUrl); + expect(restored.read).toBe(true); + expect(restored.write).toBe(false); + }); + }); + + describe('toSnapshot', () => { + it('should create valid snapshot', () => { + const relay = Relay.create(testIdentityId, validUrl, true, false); + const snapshot = relay.toSnapshot(); + + expect(snapshot.identityId).toEqual(testIdentityId.toString()); + expect(snapshot.url).toEqual(validUrl); + expect(snapshot.read).toBe(true); + expect(snapshot.write).toBe(false); + }); + }); +}); + +describe('toNip65RelayList', () => { + const identityId = IdentityId.from('identity-1'); + + it('should convert relays to NIP-65 format', () => { + const relays = [ + Relay.create(identityId, 'wss://relay1.com', true, true), + Relay.create(identityId, 'wss://relay2.com', true, false), + Relay.create(identityId, 'wss://relay3.com', false, true), + ]; + + const nip65List = toNip65RelayList(relays); + + expect(nip65List['wss://relay1.com']).toEqual({ read: true, write: true }); + expect(nip65List['wss://relay2.com']).toEqual({ read: true, write: false }); + expect(nip65List['wss://relay3.com']).toEqual({ read: false, write: true }); + }); + + it('should return empty object for empty relay list', () => { + const nip65List = toNip65RelayList([]); + + expect(nip65List).toEqual({}); + }); +}); diff --git a/projects/common/src/lib/domain/entities/relay.ts b/projects/common/src/lib/domain/entities/relay.ts new file mode 100644 index 0000000..a940940 --- /dev/null +++ b/projects/common/src/lib/domain/entities/relay.ts @@ -0,0 +1,268 @@ +import { IdentityId, RelayId } from '../value-objects'; +import type { RelaySnapshot } from '../repositories/relay-repository'; + +/** + * Relay entity - represents a Nostr relay configuration for an identity. + */ +export class Relay { + private readonly _id: RelayId; + private readonly _identityId: IdentityId; + private _url: string; + private _read: boolean; + private _write: boolean; + + private constructor( + id: RelayId, + identityId: IdentityId, + url: string, + read: boolean, + write: boolean + ) { + this._id = id; + this._identityId = identityId; + this._url = Relay.normalizeUrl(url); + this._read = read; + this._write = write; + } + + // ───────────────────────────────────────────────────────────────────────── + // Factory Methods + // ───────────────────────────────────────────────────────────────────────── + + /** + * Create a new relay configuration. + * + * @param identityId - The identity this relay belongs to + * @param url - The relay WebSocket URL + * @param read - Whether to read events from this relay + * @param write - Whether to write events to this relay + */ + static create( + identityId: IdentityId, + url: string, + read = true, + write = true + ): Relay { + Relay.validateUrl(url); + + return new Relay( + RelayId.generate(), + identityId, + url, + read, + write + ); + } + + /** + * Reconstitute a relay from storage. + */ + static fromSnapshot(snapshot: RelaySnapshot): Relay { + return new Relay( + RelayId.from(snapshot.id), + IdentityId.from(snapshot.identityId), + snapshot.url, + snapshot.read, + snapshot.write + ); + } + + // ───────────────────────────────────────────────────────────────────────── + // Getters + // ───────────────────────────────────────────────────────────────────────── + + get id(): RelayId { + return this._id; + } + + get identityId(): IdentityId { + return this._identityId; + } + + get url(): string { + return this._url; + } + + get read(): boolean { + return this._read; + } + + get write(): boolean { + return this._write; + } + + // ───────────────────────────────────────────────────────────────────────── + // Behavior + // ───────────────────────────────────────────────────────────────────────── + + /** + * Update the relay URL. + */ + updateUrl(newUrl: string): void { + Relay.validateUrl(newUrl); + this._url = Relay.normalizeUrl(newUrl); + } + + /** + * Enable reading from this relay. + */ + enableRead(): void { + this._read = true; + } + + /** + * Disable reading from this relay. + */ + disableRead(): void { + this._read = false; + } + + /** + * Enable writing to this relay. + */ + enableWrite(): void { + this._write = true; + } + + /** + * Disable writing to this relay. + */ + disableWrite(): void { + this._write = false; + } + + /** + * Set both read and write permissions. + */ + setPermissions(read: boolean, write: boolean): void { + this._read = read; + this._write = write; + } + + /** + * Check if this relay is enabled for either read or write. + */ + isEnabled(): boolean { + return this._read || this._write; + } + + /** + * Check if this relay has the same URL as another (case-insensitive). + */ + hasSameUrl(url: string): boolean { + return this._url.toLowerCase() === Relay.normalizeUrl(url).toLowerCase(); + } + + /** + * Check if this relay belongs to a specific identity. + */ + belongsTo(identityId: IdentityId): boolean { + return this._identityId.equals(identityId); + } + + // ───────────────────────────────────────────────────────────────────────── + // Persistence + // ───────────────────────────────────────────────────────────────────────── + + /** + * Convert to a snapshot for persistence. + */ + toSnapshot(): RelaySnapshot { + return { + id: this._id.value, + identityId: this._identityId.value, + url: this._url, + read: this._read, + write: this._write, + }; + } + + /** + * Create a clone for modification without affecting the original. + */ + clone(): Relay { + return new Relay( + this._id, + this._identityId, + this._url, + this._read, + this._write + ); + } + + // ───────────────────────────────────────────────────────────────────────── + // Equality + // ───────────────────────────────────────────────────────────────────────── + + /** + * Check equality based on relay ID. + */ + equals(other: Relay): boolean { + return this._id.equals(other._id); + } + + // ───────────────────────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────────────────────── + + private static normalizeUrl(url: string): string { + let normalized = url.trim(); + // Remove trailing slash + if (normalized.endsWith('/')) { + normalized = normalized.slice(0, -1); + } + return normalized; + } + + private static validateUrl(url: string): void { + const normalized = Relay.normalizeUrl(url); + + if (!normalized) { + throw new InvalidRelayUrlError('Relay URL cannot be empty'); + } + + // Must start with wss:// or ws:// + if (!normalized.startsWith('wss://') && !normalized.startsWith('ws://')) { + throw new InvalidRelayUrlError( + 'Relay URL must start with wss:// or ws://' + ); + } + + // Try to parse as URL + try { + new URL(normalized); + } catch { + throw new InvalidRelayUrlError(`Invalid relay URL: ${url}`); + } + } +} + +/** + * Error thrown when a relay URL is invalid. + */ +export class InvalidRelayUrlError extends Error { + constructor(message: string) { + super(message); + this.name = 'InvalidRelayUrlError'; + } +} + +/** + * Helper to convert relay list to NIP-65 format. + */ +export function toNip65RelayList( + relays: Relay[] +): Record { + const result: Record = {}; + + for (const relay of relays) { + if (relay.isEnabled()) { + result[relay.url] = { + read: relay.read, + write: relay.write, + }; + } + } + + return result; +} diff --git a/projects/common/src/lib/domain/events/domain-event.spec.ts b/projects/common/src/lib/domain/events/domain-event.spec.ts new file mode 100644 index 0000000..fd16228 --- /dev/null +++ b/projects/common/src/lib/domain/events/domain-event.spec.ts @@ -0,0 +1,81 @@ +import { DomainEvent, AggregateRoot } from './domain-event'; + +// Concrete implementation for testing +class TestEvent extends DomainEvent { + readonly eventType = 'test.event'; + + constructor(readonly testData: string) { + super(); + } +} + +class TestAggregate extends AggregateRoot { + doSomething(data: string): void { + this.addDomainEvent(new TestEvent(data)); + } +} + +describe('DomainEvent', () => { + describe('base properties', () => { + it('should have occurredAt timestamp', () => { + const before = new Date(); + const event = new TestEvent('test'); + const after = new Date(); + + expect(event.occurredAt.getTime()).toBeGreaterThanOrEqual(before.getTime()); + expect(event.occurredAt.getTime()).toBeLessThanOrEqual(after.getTime()); + }); + + it('should have unique eventId', () => { + const event1 = new TestEvent('test1'); + const event2 = new TestEvent('test2'); + + expect(event1.eventId).not.toEqual(event2.eventId); + }); + + it('should have eventType from subclass', () => { + const event = new TestEvent('test'); + + expect(event.eventType).toEqual('test.event'); + }); + }); +}); + +describe('AggregateRoot', () => { + describe('domain events', () => { + it('should collect domain events', () => { + const aggregate = new TestAggregate(); + + aggregate.doSomething('first'); + aggregate.doSomething('second'); + + const events = aggregate.pullDomainEvents(); + + expect(events.length).toBe(2); + expect((events[0] as TestEvent).testData).toEqual('first'); + expect((events[1] as TestEvent).testData).toEqual('second'); + }); + + it('should clear events after pulling', () => { + const aggregate = new TestAggregate(); + aggregate.doSomething('test'); + + aggregate.pullDomainEvents(); + const secondPull = aggregate.pullDomainEvents(); + + expect(secondPull.length).toBe(0); + }); + + it('should preserve event order', () => { + const aggregate = new TestAggregate(); + + aggregate.doSomething('1'); + aggregate.doSomething('2'); + aggregate.doSomething('3'); + + const events = aggregate.pullDomainEvents(); + + expect(events.map(e => (e as TestEvent).testData)).toEqual(['1', '2', '3']); + }); + }); +}); diff --git a/projects/common/src/lib/domain/events/domain-event.ts b/projects/common/src/lib/domain/events/domain-event.ts new file mode 100644 index 0000000..b8b863e --- /dev/null +++ b/projects/common/src/lib/domain/events/domain-event.ts @@ -0,0 +1,55 @@ +/** + * Base class for all domain events. + * Domain events capture significant occurrences in the domain that + * domain experts care about. + */ +export abstract class DomainEvent { + readonly occurredAt: Date; + readonly eventId: string; + + constructor() { + this.occurredAt = new Date(); + this.eventId = crypto.randomUUID(); + } + + /** + * Get the event type identifier. + * Used for event routing and serialization. + */ + abstract get eventType(): string; +} + +/** + * Interface for entities that can raise domain events. + */ +export interface EventRaiser { + /** + * Pull all pending domain events from the entity. + * This clears the internal event list. + */ + pullDomainEvents(): DomainEvent[]; +} + +/** + * Base class for aggregate roots that can raise domain events. + */ +export abstract class AggregateRoot implements EventRaiser { + private _domainEvents: DomainEvent[] = []; + + protected addDomainEvent(event: DomainEvent): void { + this._domainEvents.push(event); + } + + pullDomainEvents(): DomainEvent[] { + const events = [...this._domainEvents]; + this._domainEvents = []; + return events; + } + + /** + * Check if there are any pending domain events. + */ + hasPendingEvents(): boolean { + return this._domainEvents.length > 0; + } +} diff --git a/projects/common/src/lib/domain/events/identity-events.spec.ts b/projects/common/src/lib/domain/events/identity-events.spec.ts new file mode 100644 index 0000000..b9eb606 --- /dev/null +++ b/projects/common/src/lib/domain/events/identity-events.spec.ts @@ -0,0 +1,110 @@ +import { + IdentityCreated, + IdentityRenamed, + IdentitySelected, + IdentitySigned, + IdentityDeleted, +} from './identity-events'; + +describe('Identity Domain Events', () => { + describe('IdentityCreated', () => { + it('should store identity creation data', () => { + const event = new IdentityCreated('id-123', 'pubkey-abc', 'Alice'); + + expect(event.identityId).toEqual('id-123'); + expect(event.publicKey).toEqual('pubkey-abc'); + expect(event.nickname).toEqual('Alice'); + }); + + it('should have correct event type', () => { + const event = new IdentityCreated('id', 'pubkey', 'name'); + + expect(event.eventType).toEqual('identity.created'); + }); + + it('should have inherited base properties', () => { + const event = new IdentityCreated('id', 'pubkey', 'name'); + + expect(event.eventId).toBeTruthy(); + expect(event.occurredAt).toBeInstanceOf(Date); + }); + }); + + describe('IdentityRenamed', () => { + it('should store rename data', () => { + const event = new IdentityRenamed('id-123', 'OldName', 'NewName'); + + expect(event.identityId).toEqual('id-123'); + expect(event.oldNickname).toEqual('OldName'); + expect(event.newNickname).toEqual('NewName'); + }); + + it('should have correct event type', () => { + const event = new IdentityRenamed('id', 'old', 'new'); + + expect(event.eventType).toEqual('identity.renamed'); + }); + }); + + describe('IdentitySelected', () => { + it('should store selection data with previous identity', () => { + const event = new IdentitySelected('id-new', 'id-old'); + + expect(event.identityId).toEqual('id-new'); + expect(event.previousIdentityId).toEqual('id-old'); + }); + + it('should handle null previous identity', () => { + const event = new IdentitySelected('id-new', null); + + expect(event.identityId).toEqual('id-new'); + expect(event.previousIdentityId).toBeNull(); + }); + + it('should have correct event type', () => { + const event = new IdentitySelected('id', null); + + expect(event.eventType).toEqual('identity.selected'); + }); + }); + + describe('IdentitySigned', () => { + it('should store signing data', () => { + const event = new IdentitySigned('id-123', 1, 'event-id-abc'); + + expect(event.identityId).toEqual('id-123'); + expect(event.eventKind).toBe(1); + expect(event.signedEventId).toEqual('event-id-abc'); + }); + + it('should have correct event type', () => { + const event = new IdentitySigned('id', 1, 'event-id'); + + expect(event.eventType).toEqual('identity.signed'); + }); + + it('should handle various event kinds', () => { + const kindExamples = [0, 1, 3, 4, 7, 30023, 10002]; + + kindExamples.forEach(kind => { + const event = new IdentitySigned('id', kind, 'event'); + expect(event.eventKind).toBe(kind); + }); + }); + }); + + describe('IdentityDeleted', () => { + it('should store deletion data', () => { + const event = new IdentityDeleted('id-123', 'pubkey-abc'); + + expect(event.identityId).toEqual('id-123'); + expect(event.publicKey).toEqual('pubkey-abc'); + }); + + it('should have correct event type', () => { + const event = new IdentityDeleted('id', 'pubkey'); + + expect(event.eventType).toEqual('identity.deleted'); + }); + }); +}); diff --git a/projects/common/src/lib/domain/events/identity-events.ts b/projects/common/src/lib/domain/events/identity-events.ts new file mode 100644 index 0000000..7956c76 --- /dev/null +++ b/projects/common/src/lib/domain/events/identity-events.ts @@ -0,0 +1,74 @@ +import { DomainEvent } from './domain-event'; + +/** + * Event raised when a new identity is created. + */ +export class IdentityCreated extends DomainEvent { + readonly eventType = 'identity.created'; + + constructor( + readonly identityId: string, + readonly publicKey: string, + readonly nickname: string + ) { + super(); + } +} + +/** + * Event raised when an identity is renamed. + */ +export class IdentityRenamed extends DomainEvent { + readonly eventType = 'identity.renamed'; + + constructor( + readonly identityId: string, + readonly oldNickname: string, + readonly newNickname: string + ) { + super(); + } +} + +/** + * Event raised when an identity is selected (made active). + */ +export class IdentitySelected extends DomainEvent { + readonly eventType = 'identity.selected'; + + constructor( + readonly identityId: string, + readonly previousIdentityId: string | null + ) { + super(); + } +} + +/** + * Event raised when an identity signs an event. + */ +export class IdentitySigned extends DomainEvent { + readonly eventType = 'identity.signed'; + + constructor( + readonly identityId: string, + readonly eventKind: number, + readonly signedEventId: string + ) { + super(); + } +} + +/** + * Event raised when an identity is deleted. + */ +export class IdentityDeleted extends DomainEvent { + readonly eventType = 'identity.deleted'; + + constructor( + readonly identityId: string, + readonly publicKey: string + ) { + super(); + } +} diff --git a/projects/common/src/lib/domain/events/index.ts b/projects/common/src/lib/domain/events/index.ts new file mode 100644 index 0000000..47b6dbf --- /dev/null +++ b/projects/common/src/lib/domain/events/index.ts @@ -0,0 +1,9 @@ +export { DomainEvent, AggregateRoot } from './domain-event'; +export type { EventRaiser } from './domain-event'; +export { + IdentityCreated, + IdentityRenamed, + IdentitySelected, + IdentitySigned, + IdentityDeleted, +} from './identity-events'; diff --git a/projects/common/src/lib/domain/index.ts b/projects/common/src/lib/domain/index.ts new file mode 100644 index 0000000..5e6c315 --- /dev/null +++ b/projects/common/src/lib/domain/index.ts @@ -0,0 +1,11 @@ +// Value Objects +export * from './value-objects'; + +// Repository Interfaces +export * from './repositories'; + +// Domain Events +export * from './events'; + +// Domain Entities +export * from './entities'; diff --git a/projects/common/src/lib/domain/repositories/identity-repository.ts b/projects/common/src/lib/domain/repositories/identity-repository.ts new file mode 100644 index 0000000..1ae87e0 --- /dev/null +++ b/projects/common/src/lib/domain/repositories/identity-repository.ts @@ -0,0 +1,89 @@ +import { IdentityId } from '../value-objects'; + +/** + * Snapshot of an identity for persistence. + * This is the data structure that gets persisted, separate from the domain entity. + */ +export interface IdentitySnapshot { + id: string; + nick: string; + privkey: string; + createdAt: string; +} + +/** + * Repository interface for Identity aggregate. + * Implementations handle encryption and storage specifics. + */ +export interface IdentityRepository { + /** + * Find an identity by its ID. + * Returns undefined if not found. + */ + findById(id: IdentityId): Promise; + + /** + * Find an identity by its public key. + * Returns undefined if not found. + */ + findByPublicKey(publicKey: string): Promise; + + /** + * Find an identity by its private key. + * Used for duplicate detection. + * Returns undefined if not found. + */ + findByPrivateKey(privateKey: string): Promise; + + /** + * Get all identities. + */ + findAll(): Promise; + + /** + * Save a new or updated identity. + * If an identity with the same ID exists, it will be updated. + */ + save(identity: IdentitySnapshot): Promise; + + /** + * Delete an identity by its ID. + * Returns true if the identity was deleted, false if it didn't exist. + */ + delete(id: IdentityId): Promise; + + /** + * Get the currently selected identity ID. + */ + getSelectedId(): Promise; + + /** + * Set the currently selected identity ID. + */ + setSelectedId(id: IdentityId | null): Promise; + + /** + * Count the total number of identities. + */ + count(): Promise; +} + +/** + * Error thrown when an identity operation fails. + */ +export class IdentityRepositoryError extends Error { + constructor( + message: string, + public readonly code: IdentityErrorCode + ) { + super(message); + this.name = 'IdentityRepositoryError'; + } +} + +export enum IdentityErrorCode { + DUPLICATE_PRIVATE_KEY = 'DUPLICATE_PRIVATE_KEY', + NOT_FOUND = 'NOT_FOUND', + ENCRYPTION_FAILED = 'ENCRYPTION_FAILED', + STORAGE_FAILED = 'STORAGE_FAILED', +} diff --git a/projects/common/src/lib/domain/repositories/index.ts b/projects/common/src/lib/domain/repositories/index.ts new file mode 100644 index 0000000..92d5bb6 --- /dev/null +++ b/projects/common/src/lib/domain/repositories/index.ts @@ -0,0 +1,30 @@ +export { + IdentityRepositoryError, + IdentityErrorCode, +} from './identity-repository'; +export type { + IdentityRepository, + IdentitySnapshot, +} from './identity-repository'; + +export { + PermissionRepositoryError, + PermissionErrorCode, +} from './permission-repository'; +export type { + PermissionRepository, + PermissionSnapshot, + PermissionQuery, + ExtensionMethod, + PermissionPolicy, +} from './permission-repository'; + +export { + RelayRepositoryError, + RelayErrorCode, +} from './relay-repository'; +export type { + RelayRepository, + RelaySnapshot, + RelayQuery, +} from './relay-repository'; diff --git a/projects/common/src/lib/domain/repositories/permission-repository.ts b/projects/common/src/lib/domain/repositories/permission-repository.ts new file mode 100644 index 0000000..056eb6e --- /dev/null +++ b/projects/common/src/lib/domain/repositories/permission-repository.ts @@ -0,0 +1,108 @@ +import { IdentityId, PermissionId } from '../value-objects'; +import type { ExtensionMethod, Nip07MethodPolicy } from '../../models/nostr'; + +// Re-export types from models for convenience +// These are the canonical definitions used throughout the app +export type { ExtensionMethod, Nip07MethodPolicy as PermissionPolicy } from '../../models/nostr'; + +// Local type alias for cleaner code +type PermissionPolicy = Nip07MethodPolicy; + +/** + * Snapshot of a permission for persistence. + */ +export interface PermissionSnapshot { + id: string; + identityId: string; + host: string; + method: ExtensionMethod; + methodPolicy: PermissionPolicy; + kind?: number; // For signEvent, filter by event kind +} + +/** + * Query criteria for finding permissions. + */ +export interface PermissionQuery { + identityId?: IdentityId; + host?: string; + method?: ExtensionMethod; + kind?: number; +} + +/** + * Repository interface for Permission aggregate. + */ +export interface PermissionRepository { + /** + * Find a permission by its ID. + */ + findById(id: PermissionId): Promise; + + /** + * Find permissions matching the query criteria. + */ + find(query: PermissionQuery): Promise; + + /** + * Find a specific permission for an identity, host, method, and optionally kind. + * This is the most common lookup for checking if an action is allowed. + */ + findExact( + identityId: IdentityId, + host: string, + method: ExtensionMethod, + kind?: number + ): Promise; + + /** + * Get all permissions for an identity. + */ + findByIdentity(identityId: IdentityId): Promise; + + /** + * Get all permissions. + */ + findAll(): Promise; + + /** + * Save a new or updated permission. + */ + save(permission: PermissionSnapshot): Promise; + + /** + * Delete a permission by its ID. + */ + delete(id: PermissionId): Promise; + + /** + * Delete all permissions for an identity. + * Used when deleting an identity (cascade delete). + */ + deleteByIdentity(identityId: IdentityId): Promise; + + /** + * Count permissions matching the query. + */ + count(query?: PermissionQuery): Promise; +} + +/** + * Error thrown when a permission operation fails. + */ +export class PermissionRepositoryError extends Error { + constructor( + message: string, + public readonly code: PermissionErrorCode + ) { + super(message); + this.name = 'PermissionRepositoryError'; + } +} + +export enum PermissionErrorCode { + NOT_FOUND = 'NOT_FOUND', + ENCRYPTION_FAILED = 'ENCRYPTION_FAILED', + DECRYPTION_FAILED = 'DECRYPTION_FAILED', + STORAGE_FAILED = 'STORAGE_FAILED', +} diff --git a/projects/common/src/lib/domain/repositories/relay-repository.ts b/projects/common/src/lib/domain/repositories/relay-repository.ts new file mode 100644 index 0000000..0b333c5 --- /dev/null +++ b/projects/common/src/lib/domain/repositories/relay-repository.ts @@ -0,0 +1,94 @@ +import { IdentityId, RelayId } from '../value-objects'; + +/** + * Snapshot of a relay for persistence. + */ +export interface RelaySnapshot { + id: string; + identityId: string; + url: string; + read: boolean; + write: boolean; +} + +/** + * Query criteria for finding relays. + */ +export interface RelayQuery { + identityId?: IdentityId; + url?: string; + read?: boolean; + write?: boolean; +} + +/** + * Repository interface for Relay aggregate. + */ +export interface RelayRepository { + /** + * Find a relay by its ID. + */ + findById(id: RelayId): Promise; + + /** + * Find relays matching the query criteria. + */ + find(query: RelayQuery): Promise; + + /** + * Find a relay by URL for a specific identity. + * Used for duplicate detection. + */ + findByUrl(identityId: IdentityId, url: string): Promise; + + /** + * Get all relays for an identity. + */ + findByIdentity(identityId: IdentityId): Promise; + + /** + * Get all relays. + */ + findAll(): Promise; + + /** + * Save a new or updated relay. + */ + save(relay: RelaySnapshot): Promise; + + /** + * Delete a relay by its ID. + */ + delete(id: RelayId): Promise; + + /** + * Delete all relays for an identity. + * Used when deleting an identity (cascade delete). + */ + deleteByIdentity(identityId: IdentityId): Promise; + + /** + * Count relays matching the query. + */ + count(query?: RelayQuery): Promise; +} + +/** + * Error thrown when a relay operation fails. + */ +export class RelayRepositoryError extends Error { + constructor( + message: string, + public readonly code: RelayErrorCode + ) { + super(message); + this.name = 'RelayRepositoryError'; + } +} + +export enum RelayErrorCode { + DUPLICATE_URL = 'DUPLICATE_URL', + NOT_FOUND = 'NOT_FOUND', + ENCRYPTION_FAILED = 'ENCRYPTION_FAILED', + STORAGE_FAILED = 'STORAGE_FAILED', +} diff --git a/projects/common/src/lib/domain/value-objects/entity-id.spec.ts b/projects/common/src/lib/domain/value-objects/entity-id.spec.ts new file mode 100644 index 0000000..703814d --- /dev/null +++ b/projects/common/src/lib/domain/value-objects/entity-id.spec.ts @@ -0,0 +1,84 @@ +import { IdentityId, PermissionId, RelayId, NwcConnectionId, CashuMintId } from './index'; + +describe('EntityId Value Objects', () => { + describe('IdentityId', () => { + it('should generate unique IDs', () => { + const id1 = IdentityId.generate(); + const id2 = IdentityId.generate(); + + expect(id1.toString()).not.toEqual(id2.toString()); + }); + + it('should create from existing string value', () => { + const value = 'test-identity-id-123'; + const id = IdentityId.from(value); + + expect(id.toString()).toEqual(value); + }); + + it('should be equal when values match', () => { + const value = 'same-id'; + const id1 = IdentityId.from(value); + const id2 = IdentityId.from(value); + + expect(id1.equals(id2)).toBe(true); + }); + + it('should not be equal when values differ', () => { + const id1 = IdentityId.from('id-1'); + const id2 = IdentityId.from('id-2'); + + expect(id1.equals(id2)).toBe(false); + }); + }); + + describe('PermissionId', () => { + it('should generate unique IDs', () => { + const id1 = PermissionId.generate(); + const id2 = PermissionId.generate(); + + expect(id1.toString()).not.toEqual(id2.toString()); + }); + + it('should create from existing string value', () => { + const value = 'test-permission-id-456'; + const id = PermissionId.from(value); + + expect(id.toString()).toEqual(value); + }); + }); + + describe('RelayId', () => { + it('should generate unique IDs', () => { + const id1 = RelayId.generate(); + const id2 = RelayId.generate(); + + expect(id1.toString()).not.toEqual(id2.toString()); + }); + + it('should create from existing string value', () => { + const value = 'test-relay-id-789'; + const id = RelayId.from(value); + + expect(id.toString()).toEqual(value); + }); + }); + + describe('NwcConnectionId', () => { + it('should generate unique IDs', () => { + const id1 = NwcConnectionId.generate(); + const id2 = NwcConnectionId.generate(); + + expect(id1.toString()).not.toEqual(id2.toString()); + }); + }); + + describe('CashuMintId', () => { + it('should generate unique IDs', () => { + const id1 = CashuMintId.generate(); + const id2 = CashuMintId.generate(); + + expect(id1.toString()).not.toEqual(id2.toString()); + }); + }); +}); diff --git a/projects/common/src/lib/domain/value-objects/entity-id.ts b/projects/common/src/lib/domain/value-objects/entity-id.ts new file mode 100644 index 0000000..fc186ac --- /dev/null +++ b/projects/common/src/lib/domain/value-objects/entity-id.ts @@ -0,0 +1,30 @@ +/** + * Base class for strongly-typed entity IDs. + * Prevents mixing up different ID types (e.g., IdentityId vs PermissionId). + */ +export abstract class EntityId { + protected constructor(protected readonly _value: string) { + if (!_value || _value.trim() === '') { + throw new Error(`${this.constructor.name} cannot be empty`); + } + } + + get value(): string { + return this._value; + } + + equals(other: EntityId): boolean { + if (!(other instanceof this.constructor)) { + return false; + } + return this._value === other._value; + } + + toString(): string { + return this._value; + } + + toJSON(): string { + return this._value; + } +} diff --git a/projects/common/src/lib/domain/value-objects/identity-id.ts b/projects/common/src/lib/domain/value-objects/identity-id.ts new file mode 100644 index 0000000..d8162de --- /dev/null +++ b/projects/common/src/lib/domain/value-objects/identity-id.ts @@ -0,0 +1,36 @@ +import { v4 as uuidv4 } from 'uuid'; +import { EntityId } from './entity-id'; + +/** + * Strongly-typed identifier for Identity entities. + * Prevents accidental mixing with other ID types. + */ +export class IdentityId extends EntityId<'IdentityId'> { + private readonly _brand = 'IdentityId' as const; + + private constructor(value: string) { + super(value); + } + + /** + * Generate a new unique IdentityId. + */ + static generate(): IdentityId { + return new IdentityId(uuidv4()); + } + + /** + * Create an IdentityId from an existing string value. + * Use this when reconstituting from storage. + */ + static from(value: string): IdentityId { + return new IdentityId(value); + } + + /** + * Type guard to check if two IDs are equal. + */ + override equals(other: IdentityId): boolean { + return other instanceof IdentityId && this._value === other._value; + } +} diff --git a/projects/common/src/lib/domain/value-objects/index.ts b/projects/common/src/lib/domain/value-objects/index.ts new file mode 100644 index 0000000..6663c93 --- /dev/null +++ b/projects/common/src/lib/domain/value-objects/index.ts @@ -0,0 +1,16 @@ +// Base +export { EntityId } from './entity-id'; + +// Entity IDs +export { IdentityId } from './identity-id'; +export { PermissionId } from './permission-id'; +export { RelayId } from './relay-id'; +export { NwcConnectionId, CashuMintId } from './wallet-id'; + +// Domain Value Objects +export { Nickname, InvalidNicknameError } from './nickname'; +export { + NostrKeyPair, + NostrPublicKey, + InvalidNostrKeyError, +} from './nostr-keypair'; diff --git a/projects/common/src/lib/domain/value-objects/nickname.spec.ts b/projects/common/src/lib/domain/value-objects/nickname.spec.ts new file mode 100644 index 0000000..3b440a6 --- /dev/null +++ b/projects/common/src/lib/domain/value-objects/nickname.spec.ts @@ -0,0 +1,95 @@ +import { Nickname, InvalidNicknameError } from './nickname'; + +describe('Nickname Value Object', () => { + describe('create', () => { + it('should create a valid nickname', () => { + const nickname = Nickname.create('Alice'); + + expect(nickname.toString()).toEqual('Alice'); + }); + + it('should trim whitespace from nickname', () => { + const nickname = Nickname.create(' Bob '); + + expect(nickname.toString()).toEqual('Bob'); + }); + + it('should throw InvalidNicknameError for empty string', () => { + expect(() => Nickname.create('')).toThrowError(InvalidNicknameError); + }); + + it('should throw InvalidNicknameError for whitespace-only string', () => { + expect(() => Nickname.create(' ')).toThrowError(InvalidNicknameError); + }); + + it('should throw InvalidNicknameError for nickname exceeding 50 characters', () => { + const longNickname = 'a'.repeat(51); + + expect(() => Nickname.create(longNickname)).toThrowError(InvalidNicknameError); + }); + + it('should allow nickname with exactly 50 characters', () => { + const maxNickname = 'a'.repeat(50); + + expect(() => Nickname.create(maxNickname)).not.toThrow(); + expect(Nickname.create(maxNickname).toString()).toEqual(maxNickname); + }); + + it('should allow single character nickname', () => { + const nickname = Nickname.create('X'); + + expect(nickname.toString()).toEqual('X'); + }); + }); + + describe('fromStorage', () => { + it('should create nickname from storage without validation', () => { + // This allows loading potentially invalid data from storage + // without throwing during deserialization + const nickname = Nickname.fromStorage('stored-nickname'); + + expect(nickname.toString()).toEqual('stored-nickname'); + }); + + it('should handle long nicknames from legacy storage', () => { + const longLegacyNickname = 'a'.repeat(100); + const nickname = Nickname.fromStorage(longLegacyNickname); + + expect(nickname.toString()).toEqual(longLegacyNickname); + }); + }); + + describe('equals', () => { + it('should return true for equal nicknames', () => { + const nick1 = Nickname.create('Alice'); + const nick2 = Nickname.create('Alice'); + + expect(nick1.equals(nick2)).toBe(true); + }); + + it('should return false for different nicknames', () => { + const nick1 = Nickname.create('Alice'); + const nick2 = Nickname.create('Bob'); + + expect(nick1.equals(nick2)).toBe(false); + }); + + it('should be case-sensitive', () => { + const nick1 = Nickname.create('alice'); + const nick2 = Nickname.create('Alice'); + + expect(nick1.equals(nick2)).toBe(false); + }); + }); + + describe('InvalidNicknameError', () => { + it('should be an instance of InvalidNicknameError', () => { + try { + Nickname.create(''); + } catch (e) { + expect(e).toBeInstanceOf(InvalidNicknameError); + expect((e as InvalidNicknameError).message).toContain('cannot be empty'); + } + }); + }); +}); diff --git a/projects/common/src/lib/domain/value-objects/nickname.ts b/projects/common/src/lib/domain/value-objects/nickname.ts new file mode 100644 index 0000000..442951f --- /dev/null +++ b/projects/common/src/lib/domain/value-objects/nickname.ts @@ -0,0 +1,66 @@ +/** + * Value object representing a user-defined nickname for an identity. + * Self-validating and immutable. + */ +export class Nickname { + private static readonly MAX_LENGTH = 50; + private static readonly MIN_LENGTH = 1; + + private constructor(private readonly _value: string) {} + + /** + * Create a new Nickname from a string value. + * Trims whitespace and validates length. + * + * @throws Error if nickname is empty or too long + */ + static create(value: string): Nickname { + const trimmed = value?.trim() ?? ''; + + if (trimmed.length < Nickname.MIN_LENGTH) { + throw new InvalidNicknameError('Nickname cannot be empty'); + } + + if (trimmed.length > Nickname.MAX_LENGTH) { + throw new InvalidNicknameError( + `Nickname cannot exceed ${Nickname.MAX_LENGTH} characters` + ); + } + + return new Nickname(trimmed); + } + + /** + * Reconstitute a Nickname from storage without validation. + * Use only when loading from trusted storage. + */ + static fromStorage(value: string): Nickname { + return new Nickname(value); + } + + get value(): string { + return this._value; + } + + equals(other: Nickname): boolean { + return this._value === other._value; + } + + toString(): string { + return this._value; + } + + toJSON(): string { + return this._value; + } +} + +/** + * Error thrown when nickname validation fails. + */ +export class InvalidNicknameError extends Error { + constructor(message: string) { + super(message); + this.name = 'InvalidNicknameError'; + } +} diff --git a/projects/common/src/lib/domain/value-objects/nostr-keypair.spec.ts b/projects/common/src/lib/domain/value-objects/nostr-keypair.spec.ts new file mode 100644 index 0000000..39fc02a --- /dev/null +++ b/projects/common/src/lib/domain/value-objects/nostr-keypair.spec.ts @@ -0,0 +1,91 @@ +import { NostrKeyPair, InvalidNostrKeyError } from './nostr-keypair'; + +describe('NostrKeyPair Value Object', () => { + // Known test vectors + const TEST_PRIVATE_KEY_HEX = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + + describe('generate', () => { + it('should generate a valid keypair', () => { + const keyPair = NostrKeyPair.generate(); + + expect(keyPair.publicKeyHex).toBeTruthy(); + expect(keyPair.publicKeyHex.length).toBe(64); + }); + + it('should generate unique keypairs each time', () => { + const keyPair1 = NostrKeyPair.generate(); + const keyPair2 = NostrKeyPair.generate(); + + expect(keyPair1.publicKeyHex).not.toEqual(keyPair2.publicKeyHex); + }); + }); + + describe('fromPrivateKey', () => { + it('should create keypair from valid hex private key', () => { + const keyPair = NostrKeyPair.fromPrivateKey(TEST_PRIVATE_KEY_HEX); + + expect(keyPair.publicKeyHex).toBeTruthy(); + expect(keyPair.publicKeyHex.length).toBe(64); + }); + + it('should throw InvalidNostrKeyError for empty string', () => { + expect(() => NostrKeyPair.fromPrivateKey('')).toThrowError(InvalidNostrKeyError); + }); + + it('should throw InvalidNostrKeyError for invalid hex', () => { + expect(() => NostrKeyPair.fromPrivateKey('not-valid-hex')).toThrowError(InvalidNostrKeyError); + }); + + it('should throw InvalidNostrKeyError for hex that is too short', () => { + const shortHex = '0123456789abcdef'; + expect(() => NostrKeyPair.fromPrivateKey(shortHex)).toThrowError(InvalidNostrKeyError); + }); + }); + + describe('public key formats', () => { + it('should return hex public key', () => { + const keyPair = NostrKeyPair.fromPrivateKey(TEST_PRIVATE_KEY_HEX); + + expect(keyPair.publicKeyHex).toMatch(/^[0-9a-f]{64}$/); + }); + + it('should return npub format', () => { + const keyPair = NostrKeyPair.fromPrivateKey(TEST_PRIVATE_KEY_HEX); + + expect(keyPair.npub).toMatch(/^npub1[a-z0-9]+$/); + }); + + it('should return nsec format', () => { + const keyPair = NostrKeyPair.fromPrivateKey(TEST_PRIVATE_KEY_HEX); + + expect(keyPair.nsec).toMatch(/^nsec1[a-z0-9]+$/); + }); + }); + + describe('getPrivateKeyBytes', () => { + it('should return 32-byte Uint8Array', () => { + const keyPair = NostrKeyPair.fromPrivateKey(TEST_PRIVATE_KEY_HEX); + const bytes = keyPair.getPrivateKeyBytes(); + + expect(bytes).toBeInstanceOf(Uint8Array); + expect(bytes.length).toBe(32); + }); + }); + + describe('toStorageHex', () => { + it('should return the hex private key for storage', () => { + const keyPair = NostrKeyPair.fromPrivateKey(TEST_PRIVATE_KEY_HEX); + + expect(keyPair.toStorageHex()).toEqual(TEST_PRIVATE_KEY_HEX); + }); + }); + + describe('deterministic derivation', () => { + it('should derive the same public key from the same private key', () => { + const keyPair1 = NostrKeyPair.fromPrivateKey(TEST_PRIVATE_KEY_HEX); + const keyPair2 = NostrKeyPair.fromPrivateKey(TEST_PRIVATE_KEY_HEX); + + expect(keyPair1.publicKeyHex).toEqual(keyPair2.publicKeyHex); + }); + }); +}); diff --git a/projects/common/src/lib/domain/value-objects/nostr-keypair.ts b/projects/common/src/lib/domain/value-objects/nostr-keypair.ts new file mode 100644 index 0000000..a281026 --- /dev/null +++ b/projects/common/src/lib/domain/value-objects/nostr-keypair.ts @@ -0,0 +1,223 @@ +import { bech32 } from '@scure/base'; +import * as utils from '@noble/curves/abstract/utils'; +import { getPublicKey, generateSecretKey } from 'nostr-tools'; + +/** + * Value object encapsulating a Nostr keypair. + * Provides type-safe access to public key operations while protecting the private key. + * + * The private key is never exposed directly - all operations that need it + * are performed through methods on this class. + */ +export class NostrKeyPair { + private readonly _privateKeyHex: string; + private readonly _publicKeyHex: string; + + private constructor(privateKeyHex: string, publicKeyHex: string) { + this._privateKeyHex = privateKeyHex; + this._publicKeyHex = publicKeyHex; + } + + /** + * Generate a new random keypair. + */ + static generate(): NostrKeyPair { + const privateKeyBytes = generateSecretKey(); + const privateKeyHex = utils.bytesToHex(privateKeyBytes); + const publicKeyHex = getPublicKey(privateKeyBytes); + return new NostrKeyPair(privateKeyHex, publicKeyHex); + } + + /** + * Create a keypair from an existing private key. + * Accepts either hex or nsec format. + * + * @throws InvalidNostrKeyError if the key is invalid + */ + static fromPrivateKey(privateKey: string): NostrKeyPair { + try { + const hex = NostrKeyPair.normalizeToHex(privateKey); + NostrKeyPair.validateHexKey(hex); + const publicKeyHex = NostrKeyPair.derivePublicKey(hex); + return new NostrKeyPair(hex, publicKeyHex); + } catch (error) { + throw new InvalidNostrKeyError( + `Invalid private key: ${error instanceof Error ? error.message : 'unknown error'}` + ); + } + } + + /** + * Reconstitute a keypair from storage. + * Assumes the stored hex is valid (from trusted source). + */ + static fromStorage(privateKeyHex: string): NostrKeyPair { + const publicKeyHex = NostrKeyPair.derivePublicKey(privateKeyHex); + return new NostrKeyPair(privateKeyHex, publicKeyHex); + } + + /** + * Get the public key in hex format. + */ + get publicKeyHex(): string { + return this._publicKeyHex; + } + + /** + * Get the public key in npub (bech32) format. + */ + get npub(): string { + const data = utils.hexToBytes(this._publicKeyHex); + const words = bech32.toWords(data); + return bech32.encode('npub', words, 5000); + } + + /** + * Get the private key in nsec (bech32) format. + * Use with caution - only for display/export purposes. + */ + get nsec(): string { + const data = utils.hexToBytes(this._privateKeyHex); + const words = bech32.toWords(data); + return bech32.encode('nsec', words, 5000); + } + + /** + * Get the private key bytes for cryptographic operations. + * Internal use only - required for signing and encryption. + */ + getPrivateKeyBytes(): Uint8Array { + return utils.hexToBytes(this._privateKeyHex); + } + + /** + * Get the private key hex for storage. + * This should only be used when persisting to encrypted storage. + */ + toStorageHex(): string { + return this._privateKeyHex; + } + + /** + * Check if this keypair has the same public key as another. + */ + hasSamePublicKey(other: NostrKeyPair): boolean { + return this._publicKeyHex === other._publicKeyHex; + } + + /** + * Check if this keypair matches a given public key. + */ + matchesPublicKey(publicKeyHex: string): boolean { + return this._publicKeyHex === publicKeyHex; + } + + /** + * Value equality based on public key. + * Two keypairs are equal if they represent the same identity. + */ + equals(other: NostrKeyPair): boolean { + return this._publicKeyHex === other._publicKeyHex; + } + + // ───────────────────────────────────────────────────────────────────────── + // Private helpers + // ───────────────────────────────────────────────────────────────────────── + + private static normalizeToHex(privateKey: string): string { + if (privateKey.startsWith('nsec')) { + return NostrKeyPair.nsecToHex(privateKey); + } + return privateKey; + } + + private static nsecToHex(nsec: string): string { + const { prefix, words } = bech32.decode(nsec as `${string}1${string}`, 5000); + if (prefix !== 'nsec') { + throw new Error('Invalid nsec prefix'); + } + const data = new Uint8Array(bech32.fromWords(words)); + return utils.bytesToHex(data); + } + + private static validateHexKey(hex: string): void { + if (!/^[0-9a-fA-F]{64}$/.test(hex)) { + throw new Error('Private key must be 64 hex characters'); + } + } + + private static derivePublicKey(privateKeyHex: string): string { + const privateKeyBytes = utils.hexToBytes(privateKeyHex); + return getPublicKey(privateKeyBytes); + } +} + +/** + * Error thrown when a Nostr key is invalid. + */ +export class InvalidNostrKeyError extends Error { + constructor(message: string) { + super(message); + this.name = 'InvalidNostrKeyError'; + } +} + +/** + * Utility functions for public key operations (no private key needed). + */ +export class NostrPublicKey { + private constructor(private readonly _hex: string) {} + + /** + * Create from hex or npub format. + */ + static from(publicKey: string): NostrPublicKey { + if (publicKey.startsWith('npub')) { + const hex = NostrPublicKey.npubToHex(publicKey); + return new NostrPublicKey(hex); + } + NostrPublicKey.validateHex(publicKey); + return new NostrPublicKey(publicKey); + } + + get hex(): string { + return this._hex; + } + + get npub(): string { + const data = utils.hexToBytes(this._hex); + const words = bech32.toWords(data); + return bech32.encode('npub', words, 5000); + } + + /** + * Get a shortened display version of the public key. + */ + shortened(prefixLength = 8, suffixLength = 4): string { + const npub = this.npub; + return `${npub.slice(0, prefixLength)}...${npub.slice(-suffixLength)}`; + } + + equals(other: NostrPublicKey): boolean { + return this._hex === other._hex; + } + + toString(): string { + return this._hex; + } + + private static npubToHex(npub: string): string { + const { prefix, words } = bech32.decode(npub as `${string}1${string}`, 5000); + if (prefix !== 'npub') { + throw new InvalidNostrKeyError('Invalid npub prefix'); + } + const data = new Uint8Array(bech32.fromWords(words)); + return utils.bytesToHex(data); + } + + private static validateHex(hex: string): void { + if (!/^[0-9a-fA-F]{64}$/.test(hex)) { + throw new InvalidNostrKeyError('Public key must be 64 hex characters'); + } + } +} diff --git a/projects/common/src/lib/domain/value-objects/permission-id.ts b/projects/common/src/lib/domain/value-objects/permission-id.ts new file mode 100644 index 0000000..e0251ca --- /dev/null +++ b/projects/common/src/lib/domain/value-objects/permission-id.ts @@ -0,0 +1,36 @@ +import { v4 as uuidv4 } from 'uuid'; +import { EntityId } from './entity-id'; + +/** + * Strongly-typed identifier for Permission entities. + * Prevents accidental mixing with other ID types. + */ +export class PermissionId extends EntityId<'PermissionId'> { + private readonly _brand = 'PermissionId' as const; + + private constructor(value: string) { + super(value); + } + + /** + * Generate a new unique PermissionId. + */ + static generate(): PermissionId { + return new PermissionId(uuidv4()); + } + + /** + * Create a PermissionId from an existing string value. + * Use this when reconstituting from storage. + */ + static from(value: string): PermissionId { + return new PermissionId(value); + } + + /** + * Type guard to check if two IDs are equal. + */ + override equals(other: PermissionId): boolean { + return other instanceof PermissionId && this._value === other._value; + } +} diff --git a/projects/common/src/lib/domain/value-objects/relay-id.ts b/projects/common/src/lib/domain/value-objects/relay-id.ts new file mode 100644 index 0000000..025290f --- /dev/null +++ b/projects/common/src/lib/domain/value-objects/relay-id.ts @@ -0,0 +1,36 @@ +import { v4 as uuidv4 } from 'uuid'; +import { EntityId } from './entity-id'; + +/** + * Strongly-typed identifier for Relay entities. + * Prevents accidental mixing with other ID types. + */ +export class RelayId extends EntityId<'RelayId'> { + private readonly _brand = 'RelayId' as const; + + private constructor(value: string) { + super(value); + } + + /** + * Generate a new unique RelayId. + */ + static generate(): RelayId { + return new RelayId(uuidv4()); + } + + /** + * Create a RelayId from an existing string value. + * Use this when reconstituting from storage. + */ + static from(value: string): RelayId { + return new RelayId(value); + } + + /** + * Type guard to check if two IDs are equal. + */ + override equals(other: RelayId): boolean { + return other instanceof RelayId && this._value === other._value; + } +} diff --git a/projects/common/src/lib/domain/value-objects/wallet-id.ts b/projects/common/src/lib/domain/value-objects/wallet-id.ts new file mode 100644 index 0000000..b8bdacf --- /dev/null +++ b/projects/common/src/lib/domain/value-objects/wallet-id.ts @@ -0,0 +1,48 @@ +import { v4 as uuidv4 } from 'uuid'; +import { EntityId } from './entity-id'; + +/** + * Strongly-typed identifier for NWC wallet connection entities. + */ +export class NwcConnectionId extends EntityId<'NwcConnectionId'> { + private readonly _brand = 'NwcConnectionId' as const; + + private constructor(value: string) { + super(value); + } + + static generate(): NwcConnectionId { + return new NwcConnectionId(uuidv4()); + } + + static from(value: string): NwcConnectionId { + return new NwcConnectionId(value); + } + + override equals(other: NwcConnectionId): boolean { + return other instanceof NwcConnectionId && this._value === other._value; + } +} + +/** + * Strongly-typed identifier for Cashu mint entities. + */ +export class CashuMintId extends EntityId<'CashuMintId'> { + private readonly _brand = 'CashuMintId' as const; + + private constructor(value: string) { + super(value); + } + + static generate(): CashuMintId { + return new CashuMintId(uuidv4()); + } + + static from(value: string): CashuMintId { + return new CashuMintId(value); + } + + override equals(other: CashuMintId): boolean { + return other instanceof CashuMintId && this._value === other._value; + } +} diff --git a/projects/common/src/lib/infrastructure/encryption/encryption-context.ts b/projects/common/src/lib/infrastructure/encryption/encryption-context.ts new file mode 100644 index 0000000..3a757be --- /dev/null +++ b/projects/common/src/lib/infrastructure/encryption/encryption-context.ts @@ -0,0 +1,67 @@ +/** + * Context containing the cryptographic parameters needed for encryption/decryption. + * This abstracts away the vault version differences (v1 PBKDF2 vs v2 Argon2id). + */ +export type EncryptionContext = + | EncryptionContextV1 + | EncryptionContextV2; + +/** + * v1: PBKDF2-derived key from password + */ +export interface EncryptionContextV1 { + version: 1; + iv: string; + password: string; +} + +/** + * v2: Pre-derived Argon2id key + */ +export interface EncryptionContextV2 { + version: 2; + iv: string; + keyBase64: string; +} + +/** + * Type guard for v1 context + */ +export function isV1Context(ctx: EncryptionContext): ctx is EncryptionContextV1 { + return ctx.version === 1; +} + +/** + * Type guard for v2 context + */ +export function isV2Context(ctx: EncryptionContext): ctx is EncryptionContextV2 { + return ctx.version === 2; +} + +/** + * Create an encryption context from session data. + * Returns undefined if no valid context can be created. + */ +export function createEncryptionContext(params: { + iv: string; + vaultPassword?: string; + vaultKey?: string; +}): EncryptionContext | undefined { + if (params.vaultKey) { + return { + version: 2, + iv: params.iv, + keyBase64: params.vaultKey, + }; + } + + if (params.vaultPassword) { + return { + version: 1, + iv: params.iv, + password: params.vaultPassword, + }; + } + + return undefined; +} diff --git a/projects/common/src/lib/infrastructure/encryption/encryption.service.ts b/projects/common/src/lib/infrastructure/encryption/encryption.service.ts new file mode 100644 index 0000000..f364ae0 --- /dev/null +++ b/projects/common/src/lib/infrastructure/encryption/encryption.service.ts @@ -0,0 +1,156 @@ +import { Buffer } from 'buffer'; +import { CryptoHelper } from '../../helpers/crypto-helper'; +import { + EncryptionContext, + isV2Context, +} from './encryption-context'; + +/** + * Service responsible for encrypting and decrypting data. + * Abstracts away vault version differences (v1 PBKDF2 vs v2 Argon2id). + * + * This is an infrastructure service - it knows nothing about domain concepts, + * only about cryptographic operations. + */ +export class EncryptionService { + constructor(private readonly context: EncryptionContext) {} + + /** + * Encrypt a string value. + */ + async encryptString(value: string): Promise { + if (isV2Context(this.context)) { + return this.encryptWithKeyV2(value); + } + return CryptoHelper.encrypt(value, this.context.iv, this.context.password); + } + + /** + * Encrypt a number value (converts to string first). + */ + async encryptNumber(value: number): Promise { + return this.encryptString(value.toString()); + } + + /** + * Encrypt a boolean value (converts to string first). + */ + async encryptBoolean(value: boolean): Promise { + return this.encryptString(value.toString()); + } + + /** + * Decrypt a value to string. + */ + async decryptString(encrypted: string): Promise { + if (isV2Context(this.context)) { + return this.decryptWithKeyV2(encrypted); + } + return CryptoHelper.decrypt(encrypted, this.context.iv, this.context.password); + } + + /** + * Decrypt a value to number. + */ + async decryptNumber(encrypted: string): Promise { + const decrypted = await this.decryptString(encrypted); + return parseInt(decrypted, 10); + } + + /** + * Decrypt a value to boolean. + */ + async decryptBoolean(encrypted: string): Promise { + const decrypted = await this.decryptString(encrypted); + return decrypted === 'true'; + } + + /** + * Get the encryption context (for serialization or passing to other services). + */ + getContext(): EncryptionContext { + return this.context; + } + + // ───────────────────────────────────────────────────────────────────────── + // V2 encryption/decryption using pre-derived Argon2id key + // ───────────────────────────────────────────────────────────────────────── + + private async encryptWithKeyV2(text: string): Promise { + if (!isV2Context(this.context)) { + throw new Error('V2 encryption requires keyBase64'); + } + + const keyBytes = Buffer.from(this.context.keyBase64, 'base64'); + const iv = Buffer.from(this.context.iv, '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'); + } + + private async decryptWithKeyV2(encryptedBase64: string): Promise { + if (!isV2Context(this.context)) { + throw new Error('V2 decryption requires keyBase64'); + } + + const keyBytes = Buffer.from(this.context.keyBase64, 'base64'); + const iv = Buffer.from(this.context.iv, '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); + } +} + +/** + * Factory function to create an EncryptionService from session data. + */ +export function createEncryptionService(params: { + iv: string; + vaultPassword?: string; + vaultKey?: string; +}): EncryptionService { + if (params.vaultKey) { + return new EncryptionService({ + version: 2, + iv: params.iv, + keyBase64: params.vaultKey, + }); + } + + if (params.vaultPassword) { + return new EncryptionService({ + version: 1, + iv: params.iv, + password: params.vaultPassword, + }); + } + + throw new Error('Either vaultPassword or vaultKey must be provided'); +} diff --git a/projects/common/src/lib/infrastructure/encryption/index.ts b/projects/common/src/lib/infrastructure/encryption/index.ts new file mode 100644 index 0000000..9e6054a --- /dev/null +++ b/projects/common/src/lib/infrastructure/encryption/index.ts @@ -0,0 +1,15 @@ +export { + isV1Context, + isV2Context, + createEncryptionContext, +} from './encryption-context'; +export type { + EncryptionContext, + EncryptionContextV1, + EncryptionContextV2, +} from './encryption-context'; + +export { + EncryptionService, + createEncryptionService, +} from './encryption.service'; diff --git a/projects/common/src/lib/infrastructure/index.ts b/projects/common/src/lib/infrastructure/index.ts new file mode 100644 index 0000000..a8ae5ae --- /dev/null +++ b/projects/common/src/lib/infrastructure/index.ts @@ -0,0 +1,2 @@ +export * from './encryption'; +export * from './repositories'; diff --git a/projects/common/src/lib/infrastructure/repositories/identity-repository.impl.ts b/projects/common/src/lib/infrastructure/repositories/identity-repository.impl.ts new file mode 100644 index 0000000..b0cb279 --- /dev/null +++ b/projects/common/src/lib/infrastructure/repositories/identity-repository.impl.ts @@ -0,0 +1,228 @@ +import { + IdentityRepositoryError, + IdentityErrorCode, +} from '../../domain/repositories/identity-repository'; +import type { + IdentityRepository, + IdentitySnapshot, +} from '../../domain/repositories/identity-repository'; +import { IdentityId } from '../../domain/value-objects'; +import { EncryptionService } from '../encryption'; +import { NostrHelper } from '../../helpers/nostr-helper'; + +/** + * Encrypted identity as stored in browser sync storage. + */ +interface EncryptedIdentity { + id: string; + nick: string; + privkey: string; + createdAt: string; +} + +/** + * Storage adapter interface - abstracts browser storage operations. + * Implementations provided by Chrome/Firefox specific code. + */ +export interface IdentityStorageAdapter { + // Session (in-memory, decrypted) operations + getSessionIdentities(): IdentitySnapshot[]; + setSessionIdentities(identities: IdentitySnapshot[]): void; + saveSessionData(): Promise; + + getSessionSelectedId(): string | null; + setSessionSelectedId(id: string | null): void; + + // Sync (persistent, encrypted) operations + getSyncIdentities(): EncryptedIdentity[]; + saveSyncIdentities(identities: EncryptedIdentity[]): Promise; + + getSyncSelectedId(): string | null; + saveSyncSelectedId(id: string | null): Promise; +} + +/** + * Implementation of IdentityRepository using browser storage. + * Handles encryption/decryption transparently. + */ +export class BrowserIdentityRepository implements IdentityRepository { + constructor( + private readonly storage: IdentityStorageAdapter, + private readonly encryption: EncryptionService + ) {} + + async findById(id: IdentityId): Promise { + const identities = this.storage.getSessionIdentities(); + return identities.find((i) => i.id === id.value); + } + + async findByPublicKey(publicKey: string): Promise { + const identities = this.storage.getSessionIdentities(); + return identities.find((i) => { + try { + const derivedPubkey = NostrHelper.pubkeyFromPrivkey(i.privkey); + return derivedPubkey === publicKey; + } catch { + return false; + } + }); + } + + async findByPrivateKey(privateKey: string): Promise { + // Normalize the private key to hex format + let privkeyHex: string; + try { + privkeyHex = NostrHelper.getNostrPrivkeyObject(privateKey.toLowerCase()).hex; + } catch { + return undefined; + } + + const identities = this.storage.getSessionIdentities(); + return identities.find((i) => i.privkey === privkeyHex); + } + + async findAll(): Promise { + return this.storage.getSessionIdentities(); + } + + async save(identity: IdentitySnapshot): Promise { + // Check for duplicate private key (excluding self) + const existing = await this.findByPrivateKey(identity.privkey); + if (existing && existing.id !== identity.id) { + throw new IdentityRepositoryError( + `An identity with the same private key already exists: ${existing.nick}`, + IdentityErrorCode.DUPLICATE_PRIVATE_KEY + ); + } + + // Update session storage + const sessionIdentities = this.storage.getSessionIdentities(); + const existingIndex = sessionIdentities.findIndex((i) => i.id === identity.id); + + if (existingIndex >= 0) { + // Update existing + sessionIdentities[existingIndex] = identity; + } else { + // Add new + sessionIdentities.push(identity); + + // Auto-select if first identity + if (sessionIdentities.length === 1) { + this.storage.setSessionSelectedId(identity.id); + } + } + + this.storage.setSessionIdentities(sessionIdentities); + await this.storage.saveSessionData(); + + // Encrypt and save to sync storage + const encryptedIdentity = await this.encryptIdentity(identity); + const syncIdentities = this.storage.getSyncIdentities(); + const syncIndex = syncIdentities.findIndex( + async (i) => (await this.encryption.decryptString(i.id)) === identity.id + ); + + if (syncIndex >= 0) { + syncIdentities[syncIndex] = encryptedIdentity; + } else { + syncIdentities.push(encryptedIdentity); + } + + await this.storage.saveSyncIdentities(syncIdentities); + + // Update selected ID in sync if this was the first identity + if (sessionIdentities.length === 1) { + const encryptedId = await this.encryption.encryptString(identity.id); + await this.storage.saveSyncSelectedId(encryptedId); + } + } + + async delete(id: IdentityId): Promise { + const sessionIdentities = this.storage.getSessionIdentities(); + const initialLength = sessionIdentities.length; + const filtered = sessionIdentities.filter((i) => i.id !== id.value); + + if (filtered.length === initialLength) { + return false; // Nothing was deleted + } + + // Update selected identity if needed + const currentSelectedId = this.storage.getSessionSelectedId(); + if (currentSelectedId === id.value) { + const newSelectedId = filtered.length > 0 ? filtered[0].id : null; + this.storage.setSessionSelectedId(newSelectedId); + } + + this.storage.setSessionIdentities(filtered); + await this.storage.saveSessionData(); + + // Remove from sync storage + const encryptedId = await this.encryption.encryptString(id.value); + const syncIdentities = this.storage.getSyncIdentities(); + const filteredSync = syncIdentities.filter((i) => i.id !== encryptedId); + await this.storage.saveSyncIdentities(filteredSync); + + // Update selected ID in sync + const newSelectedId = this.storage.getSessionSelectedId(); + const encryptedSelectedId = newSelectedId + ? await this.encryption.encryptString(newSelectedId) + : null; + await this.storage.saveSyncSelectedId(encryptedSelectedId); + + return true; + } + + async getSelectedId(): Promise { + const selectedId = this.storage.getSessionSelectedId(); + return selectedId ? IdentityId.from(selectedId) : null; + } + + async setSelectedId(id: IdentityId | null): Promise { + if (id) { + // Verify the identity exists + const exists = await this.findById(id); + if (!exists) { + throw new IdentityRepositoryError( + `Identity not found: ${id.value}`, + IdentityErrorCode.NOT_FOUND + ); + } + } + + this.storage.setSessionSelectedId(id?.value ?? null); + await this.storage.saveSessionData(); + + // Update sync storage + const encryptedId = id + ? await this.encryption.encryptString(id.value) + : null; + await this.storage.saveSyncSelectedId(encryptedId); + } + + async count(): Promise { + return this.storage.getSessionIdentities().length; + } + + // ───────────────────────────────────────────────────────────────────────── + // Private helpers + // ───────────────────────────────────────────────────────────────────────── + + private async encryptIdentity(identity: IdentitySnapshot): Promise { + return { + id: await this.encryption.encryptString(identity.id), + nick: await this.encryption.encryptString(identity.nick), + privkey: await this.encryption.encryptString(identity.privkey), + createdAt: await this.encryption.encryptString(identity.createdAt), + }; + } +} + +/** + * Factory function to create a BrowserIdentityRepository. + */ +export function createIdentityRepository( + storage: IdentityStorageAdapter, + encryption: EncryptionService +): IdentityRepository { + return new BrowserIdentityRepository(storage, encryption); +} diff --git a/projects/common/src/lib/infrastructure/repositories/index.ts b/projects/common/src/lib/infrastructure/repositories/index.ts new file mode 100644 index 0000000..b2d8523 --- /dev/null +++ b/projects/common/src/lib/infrastructure/repositories/index.ts @@ -0,0 +1,17 @@ +export { + BrowserIdentityRepository, + createIdentityRepository, +} from './identity-repository.impl'; +export type { IdentityStorageAdapter } from './identity-repository.impl'; + +export { + BrowserPermissionRepository, + createPermissionRepository, +} from './permission-repository.impl'; +export type { PermissionStorageAdapter } from './permission-repository.impl'; + +export { + BrowserRelayRepository, + createRelayRepository, +} from './relay-repository.impl'; +export type { RelayStorageAdapter } from './relay-repository.impl'; diff --git a/projects/common/src/lib/infrastructure/repositories/permission-repository.impl.ts b/projects/common/src/lib/infrastructure/repositories/permission-repository.impl.ts new file mode 100644 index 0000000..edcecc9 --- /dev/null +++ b/projects/common/src/lib/infrastructure/repositories/permission-repository.impl.ts @@ -0,0 +1,218 @@ +import type { + PermissionRepository, + PermissionSnapshot, + PermissionQuery, + ExtensionMethod, +} from '../../domain/repositories/permission-repository'; +import { IdentityId, PermissionId } from '../../domain/value-objects'; +import { EncryptionService } from '../encryption'; + +/** + * Encrypted permission as stored in browser sync storage. + */ +interface EncryptedPermission { + id: string; + identityId: string; + host: string; + method: string; + methodPolicy: string; + kind?: string; +} + +/** + * Storage adapter interface for permissions. + */ +export interface PermissionStorageAdapter { + // Session (in-memory, decrypted) operations + getSessionPermissions(): PermissionSnapshot[]; + setSessionPermissions(permissions: PermissionSnapshot[]): void; + saveSessionData(): Promise; + + // Sync (persistent, encrypted) operations + getSyncPermissions(): EncryptedPermission[]; + saveSyncPermissions(permissions: EncryptedPermission[]): Promise; +} + +/** + * Implementation of PermissionRepository using browser storage. + */ +export class BrowserPermissionRepository implements PermissionRepository { + constructor( + private readonly storage: PermissionStorageAdapter, + private readonly encryption: EncryptionService + ) {} + + async findById(id: PermissionId): Promise { + const permissions = this.storage.getSessionPermissions(); + return permissions.find((p) => p.id === id.value); + } + + async find(query: PermissionQuery): Promise { + let permissions = this.storage.getSessionPermissions(); + + if (query.identityId) { + const identityIdValue = query.identityId.value; + permissions = permissions.filter((p) => p.identityId === identityIdValue); + } + if (query.host) { + const host = query.host; + permissions = permissions.filter((p) => p.host === host); + } + if (query.method) { + const method = query.method; + permissions = permissions.filter((p) => p.method === method); + } + if (query.kind !== undefined) { + const kind = query.kind; + permissions = permissions.filter((p) => p.kind === kind); + } + + return permissions; + } + + async findExact( + identityId: IdentityId, + host: string, + method: ExtensionMethod, + kind?: number + ): Promise { + const permissions = this.storage.getSessionPermissions(); + return permissions.find( + (p) => + p.identityId === identityId.value && + p.host === host && + p.method === method && + (kind === undefined ? p.kind === undefined : p.kind === kind) + ); + } + + async findByIdentity(identityId: IdentityId): Promise { + const permissions = this.storage.getSessionPermissions(); + return permissions.filter((p) => p.identityId === identityId.value); + } + + async findAll(): Promise { + return this.storage.getSessionPermissions(); + } + + async save(permission: PermissionSnapshot): Promise { + const sessionPermissions = this.storage.getSessionPermissions(); + const existingIndex = sessionPermissions.findIndex((p) => p.id === permission.id); + + if (existingIndex >= 0) { + sessionPermissions[existingIndex] = permission; + } else { + sessionPermissions.push(permission); + } + + this.storage.setSessionPermissions(sessionPermissions); + await this.storage.saveSessionData(); + + // Encrypt and save to sync storage + const encryptedPermission = await this.encryptPermission(permission); + const syncPermissions = this.storage.getSyncPermissions(); + + // Find by decrypting IDs (expensive but necessary for updates) + let syncIndex = -1; + for (let i = 0; i < syncPermissions.length; i++) { + try { + const decryptedId = await this.encryption.decryptString(syncPermissions[i].id); + if (decryptedId === permission.id) { + syncIndex = i; + break; + } + } catch { + // Skip corrupted entries + } + } + + if (syncIndex >= 0) { + syncPermissions[syncIndex] = encryptedPermission; + } else { + syncPermissions.push(encryptedPermission); + } + + await this.storage.saveSyncPermissions(syncPermissions); + } + + async delete(id: PermissionId): Promise { + const sessionPermissions = this.storage.getSessionPermissions(); + const initialLength = sessionPermissions.length; + const filtered = sessionPermissions.filter((p) => p.id !== id.value); + + if (filtered.length === initialLength) { + return false; + } + + this.storage.setSessionPermissions(filtered); + await this.storage.saveSessionData(); + + // Remove from sync storage + const encryptedId = await this.encryption.encryptString(id.value); + const syncPermissions = this.storage.getSyncPermissions(); + const filteredSync = syncPermissions.filter((p) => p.id !== encryptedId); + await this.storage.saveSyncPermissions(filteredSync); + + return true; + } + + async deleteByIdentity(identityId: IdentityId): Promise { + const sessionPermissions = this.storage.getSessionPermissions(); + const initialLength = sessionPermissions.length; + const filtered = sessionPermissions.filter((p) => p.identityId !== identityId.value); + const deletedCount = initialLength - filtered.length; + + if (deletedCount === 0) { + return 0; + } + + this.storage.setSessionPermissions(filtered); + await this.storage.saveSessionData(); + + // Remove from sync storage + const encryptedIdentityId = await this.encryption.encryptString(identityId.value); + const syncPermissions = this.storage.getSyncPermissions(); + const filteredSync = syncPermissions.filter((p) => p.identityId !== encryptedIdentityId); + await this.storage.saveSyncPermissions(filteredSync); + + return deletedCount; + } + + async count(query?: PermissionQuery): Promise { + if (query) { + const results = await this.find(query); + return results.length; + } + return this.storage.getSessionPermissions().length; + } + + // ───────────────────────────────────────────────────────────────────────── + // Private helpers + // ───────────────────────────────────────────────────────────────────────── + + private async encryptPermission(permission: PermissionSnapshot): Promise { + const encrypted: EncryptedPermission = { + id: await this.encryption.encryptString(permission.id), + identityId: await this.encryption.encryptString(permission.identityId), + host: await this.encryption.encryptString(permission.host), + method: await this.encryption.encryptString(permission.method), + methodPolicy: await this.encryption.encryptString(permission.methodPolicy), + }; + + if (permission.kind !== undefined) { + encrypted.kind = await this.encryption.encryptNumber(permission.kind); + } + + return encrypted; + } +} + +/** + * Factory function to create a BrowserPermissionRepository. + */ +export function createPermissionRepository( + storage: PermissionStorageAdapter, + encryption: EncryptionService +): PermissionRepository { + return new BrowserPermissionRepository(storage, encryption); +} diff --git a/projects/common/src/lib/infrastructure/repositories/relay-repository.impl.ts b/projects/common/src/lib/infrastructure/repositories/relay-repository.impl.ts new file mode 100644 index 0000000..a07bccb --- /dev/null +++ b/projects/common/src/lib/infrastructure/repositories/relay-repository.impl.ts @@ -0,0 +1,219 @@ +import { + RelayRepositoryError, + RelayErrorCode, +} from '../../domain/repositories/relay-repository'; +import type { + RelayRepository, + RelaySnapshot, + RelayQuery, +} from '../../domain/repositories/relay-repository'; +import { IdentityId, RelayId } from '../../domain/value-objects'; +import { EncryptionService } from '../encryption'; + +/** + * Encrypted relay as stored in browser sync storage. + */ +interface EncryptedRelay { + id: string; + identityId: string; + url: string; + read: string; + write: string; +} + +/** + * Storage adapter interface for relays. + */ +export interface RelayStorageAdapter { + // Session (in-memory, decrypted) operations + getSessionRelays(): RelaySnapshot[]; + setSessionRelays(relays: RelaySnapshot[]): void; + saveSessionData(): Promise; + + // Sync (persistent, encrypted) operations + getSyncRelays(): EncryptedRelay[]; + saveSyncRelays(relays: EncryptedRelay[]): Promise; +} + +/** + * Implementation of RelayRepository using browser storage. + */ +export class BrowserRelayRepository implements RelayRepository { + constructor( + private readonly storage: RelayStorageAdapter, + private readonly encryption: EncryptionService + ) {} + + async findById(id: RelayId): Promise { + const relays = this.storage.getSessionRelays(); + return relays.find((r) => r.id === id.value); + } + + async find(query: RelayQuery): Promise { + let relays = this.storage.getSessionRelays(); + + if (query.identityId) { + const identityIdValue = query.identityId.value; + relays = relays.filter((r) => r.identityId === identityIdValue); + } + if (query.url) { + const urlLower = query.url.toLowerCase(); + relays = relays.filter((r) => r.url.toLowerCase() === urlLower); + } + if (query.read !== undefined) { + const read = query.read; + relays = relays.filter((r) => r.read === read); + } + if (query.write !== undefined) { + const write = query.write; + relays = relays.filter((r) => r.write === write); + } + + return relays; + } + + async findByUrl(identityId: IdentityId, url: string): Promise { + const relays = this.storage.getSessionRelays(); + return relays.find( + (r) => + r.identityId === identityId.value && + r.url.toLowerCase() === url.toLowerCase() + ); + } + + async findByIdentity(identityId: IdentityId): Promise { + const relays = this.storage.getSessionRelays(); + return relays.filter((r) => r.identityId === identityId.value); + } + + async findAll(): Promise { + return this.storage.getSessionRelays(); + } + + async save(relay: RelaySnapshot): Promise { + // Check for duplicate URL for the same identity (excluding self) + const existing = await this.findByUrl( + IdentityId.from(relay.identityId), + relay.url + ); + if (existing && existing.id !== relay.id) { + throw new RelayRepositoryError( + 'A relay with the same URL already exists for this identity', + RelayErrorCode.DUPLICATE_URL + ); + } + + const sessionRelays = this.storage.getSessionRelays(); + const existingIndex = sessionRelays.findIndex((r) => r.id === relay.id); + + if (existingIndex >= 0) { + sessionRelays[existingIndex] = relay; + } else { + sessionRelays.push(relay); + } + + this.storage.setSessionRelays(sessionRelays); + await this.storage.saveSessionData(); + + // Encrypt and save to sync storage + const encryptedRelay = await this.encryptRelay(relay); + const syncRelays = this.storage.getSyncRelays(); + + // Find by decrypting IDs + let syncIndex = -1; + for (let i = 0; i < syncRelays.length; i++) { + try { + const decryptedId = await this.encryption.decryptString(syncRelays[i].id); + if (decryptedId === relay.id) { + syncIndex = i; + break; + } + } catch { + // Skip corrupted entries + } + } + + if (syncIndex >= 0) { + syncRelays[syncIndex] = encryptedRelay; + } else { + syncRelays.push(encryptedRelay); + } + + await this.storage.saveSyncRelays(syncRelays); + } + + async delete(id: RelayId): Promise { + const sessionRelays = this.storage.getSessionRelays(); + const initialLength = sessionRelays.length; + const filtered = sessionRelays.filter((r) => r.id !== id.value); + + if (filtered.length === initialLength) { + return false; + } + + this.storage.setSessionRelays(filtered); + await this.storage.saveSessionData(); + + // Remove from sync storage + const encryptedId = await this.encryption.encryptString(id.value); + const syncRelays = this.storage.getSyncRelays(); + const filteredSync = syncRelays.filter((r) => r.id !== encryptedId); + await this.storage.saveSyncRelays(filteredSync); + + return true; + } + + async deleteByIdentity(identityId: IdentityId): Promise { + const sessionRelays = this.storage.getSessionRelays(); + const initialLength = sessionRelays.length; + const filtered = sessionRelays.filter((r) => r.identityId !== identityId.value); + const deletedCount = initialLength - filtered.length; + + if (deletedCount === 0) { + return 0; + } + + this.storage.setSessionRelays(filtered); + await this.storage.saveSessionData(); + + // Remove from sync storage + const encryptedIdentityId = await this.encryption.encryptString(identityId.value); + const syncRelays = this.storage.getSyncRelays(); + const filteredSync = syncRelays.filter((r) => r.identityId !== encryptedIdentityId); + await this.storage.saveSyncRelays(filteredSync); + + return deletedCount; + } + + async count(query?: RelayQuery): Promise { + if (query) { + const results = await this.find(query); + return results.length; + } + return this.storage.getSessionRelays().length; + } + + // ───────────────────────────────────────────────────────────────────────── + // Private helpers + // ───────────────────────────────────────────────────────────────────────── + + private async encryptRelay(relay: RelaySnapshot): Promise { + return { + id: await this.encryption.encryptString(relay.id), + identityId: await this.encryption.encryptString(relay.identityId), + url: await this.encryption.encryptString(relay.url), + read: await this.encryption.encryptBoolean(relay.read), + write: await this.encryption.encryptBoolean(relay.write), + }; + } +} + +/** + * Factory function to create a BrowserRelayRepository. + */ +export function createRelayRepository( + storage: RelayStorageAdapter, + encryption: EncryptionService +): RelayRepository { + return new BrowserRelayRepository(storage, encryption); +} diff --git a/projects/common/src/lib/services/storage/browser-session-handler.ts b/projects/common/src/lib/services/storage/browser-session-handler.ts index 78e973b..31ef5fd 100644 --- a/projects/common/src/lib/services/storage/browser-session-handler.ts +++ b/projects/common/src/lib/services/storage/browser-session-handler.ts @@ -1,12 +1,17 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { BrowserSessionData } from './types'; +import { VaultSession } from './types'; export abstract class BrowserSessionHandler { - get browserSessionData(): BrowserSessionData | undefined { - return this.#browserSessionData; + get vaultSession(): VaultSession | undefined { + return this.#vaultSession; } - #browserSessionData?: BrowserSessionData; + /** @deprecated Use vaultSession instead */ + get browserSessionData(): VaultSession | undefined { + return this.#vaultSession; + } + + #vaultSession?: VaultSession; /** * Load the data from the browser session storage. It should be an empty object, @@ -16,12 +21,12 @@ export abstract class BrowserSessionHandler { * ATTENTION: Make sure to call "setFullData(..)" afterwards to update the in-memory data. */ abstract loadFullData(): Promise>>; - setFullData(data: BrowserSessionData) { - this.#browserSessionData = JSON.parse(JSON.stringify(data)); + setFullData(data: VaultSession) { + this.#vaultSession = JSON.parse(JSON.stringify(data)); } clearInMemoryData() { - this.#browserSessionData = undefined; + this.#vaultSession = undefined; } /** @@ -29,7 +34,7 @@ export abstract class BrowserSessionHandler { * * ATTENTION: Make sure to call "setFullData(..)" afterwards of before to update the in-memory data. */ - abstract saveFullData(data: BrowserSessionData): Promise; + abstract saveFullData(data: VaultSession): Promise; abstract clearData(): Promise; } diff --git a/projects/common/src/lib/services/storage/browser-sync-handler.ts b/projects/common/src/lib/services/storage/browser-sync-handler.ts index 992ac7c..bb8ae35 100644 --- a/projects/common/src/lib/services/storage/browser-sync-handler.ts +++ b/projects/common/src/lib/services/storage/browser-sync-handler.ts @@ -1,11 +1,11 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { - BrowserSyncData, - CashuMint_ENCRYPTED, - Identity_ENCRYPTED, - NwcConnection_ENCRYPTED, - Permission_ENCRYPTED, - Relay_ENCRYPTED, + EncryptedVault, + StoredCashuMint, + StoredIdentity, + StoredNwcConnection, + StoredPermission, + StoredRelay, } from './types'; /** @@ -14,15 +14,20 @@ import { * some unencrypted properties (like, version and the vault hash). */ export abstract class BrowserSyncHandler { - get browserSyncData(): BrowserSyncData | undefined { - return this.#browserSyncData; + get encryptedVault(): EncryptedVault | undefined { + return this.#encryptedVault; + } + + /** @deprecated Use encryptedVault instead */ + get browserSyncData(): EncryptedVault | undefined { + return this.#encryptedVault; } get ignoreProperties(): string[] { return this.#ignoreProperties; } - #browserSyncData?: BrowserSyncData; + #encryptedVault?: EncryptedVault; #ignoreProperties: string[] = []; setIgnoreProperties(properties: string[]) { @@ -41,10 +46,10 @@ export abstract class BrowserSyncHandler { * * ATTENTION: In your implementation, make sure to call "setFullData(..)" at the end to update the in-memory data. */ - abstract saveAndSetFullData(data: BrowserSyncData): Promise; + abstract saveAndSetFullData(data: EncryptedVault): Promise; - setFullData(data: BrowserSyncData) { - this.#browserSyncData = JSON.parse(JSON.stringify(data)); + setFullData(data: EncryptedVault) { + this.#encryptedVault = JSON.parse(JSON.stringify(data)); } /** @@ -53,13 +58,13 @@ export abstract class BrowserSyncHandler { * ATTENTION: In your implementation, make sure to call "setPartialData_Permissions(..)" at the end to update the in-memory data. */ abstract saveAndSetPartialData_Permissions(data: { - permissions: Permission_ENCRYPTED[]; + permissions: StoredPermission[]; }): Promise; - setPartialData_Permissions(data: { permissions: Permission_ENCRYPTED[] }) { - if (!this.#browserSyncData) { + setPartialData_Permissions(data: { permissions: StoredPermission[] }) { + if (!this.#encryptedVault) { return; } - this.#browserSyncData.permissions = Array.from(data.permissions); + this.#encryptedVault.permissions = Array.from(data.permissions); } /** @@ -68,14 +73,14 @@ export abstract class BrowserSyncHandler { * ATTENTION: In your implementation, make sure to call "setPartialData_Identities(..)" at the end to update the in-memory data. */ abstract saveAndSetPartialData_Identities(data: { - identities: Identity_ENCRYPTED[]; + identities: StoredIdentity[]; }): Promise; - setPartialData_Identities(data: { identities: Identity_ENCRYPTED[] }) { - if (!this.#browserSyncData) { + setPartialData_Identities(data: { identities: StoredIdentity[] }) { + if (!this.#encryptedVault) { return; } - this.#browserSyncData.identities = Array.from(data.identities); + this.#encryptedVault.identities = Array.from(data.identities); } /** @@ -90,20 +95,20 @@ export abstract class BrowserSyncHandler { setPartialData_SelectedIdentityId(data: { selectedIdentityId: string | null; }) { - if (!this.#browserSyncData) { + if (!this.#encryptedVault) { return; } - this.#browserSyncData.selectedIdentityId = data.selectedIdentityId; + this.#encryptedVault.selectedIdentityId = data.selectedIdentityId; } abstract saveAndSetPartialData_Relays(data: { - relays: Relay_ENCRYPTED[]; + relays: StoredRelay[]; }): Promise; - setPartialData_Relays(data: { relays: Relay_ENCRYPTED[] }) { - if (!this.#browserSyncData) { + setPartialData_Relays(data: { relays: StoredRelay[] }) { + if (!this.#encryptedVault) { return; } - this.#browserSyncData.relays = Array.from(data.relays); + this.#encryptedVault.relays = Array.from(data.relays); } /** @@ -112,15 +117,15 @@ export abstract class BrowserSyncHandler { * ATTENTION: In your implementation, make sure to call "setPartialData_NwcConnections(..)" at the end to update the in-memory data. */ abstract saveAndSetPartialData_NwcConnections(data: { - nwcConnections: NwcConnection_ENCRYPTED[]; + nwcConnections: StoredNwcConnection[]; }): Promise; setPartialData_NwcConnections(data: { - nwcConnections: NwcConnection_ENCRYPTED[]; + nwcConnections: StoredNwcConnection[]; }) { - if (!this.#browserSyncData) { + if (!this.#encryptedVault) { return; } - this.#browserSyncData.nwcConnections = Array.from(data.nwcConnections); + this.#encryptedVault.nwcConnections = Array.from(data.nwcConnections); } /** @@ -129,13 +134,13 @@ export abstract class BrowserSyncHandler { * ATTENTION: In your implementation, make sure to call "setPartialData_CashuMints(..)" at the end to update the in-memory data. */ abstract saveAndSetPartialData_CashuMints(data: { - cashuMints: CashuMint_ENCRYPTED[]; + cashuMints: StoredCashuMint[]; }): Promise; - setPartialData_CashuMints(data: { cashuMints: CashuMint_ENCRYPTED[] }) { - if (!this.#browserSyncData) { + setPartialData_CashuMints(data: { cashuMints: StoredCashuMint[] }) { + if (!this.#encryptedVault) { return; } - this.#browserSyncData.cashuMints = Array.from(data.cashuMints); + this.#encryptedVault.cashuMints = Array.from(data.cashuMints); } /** diff --git a/projects/common/src/lib/services/storage/signer-meta-handler.ts b/projects/common/src/lib/services/storage/signer-meta-handler.ts index 3a3cddd..0eb5c7c 100644 --- a/projects/common/src/lib/services/storage/signer-meta-handler.ts +++ b/projects/common/src/lib/services/storage/signer-meta-handler.ts @@ -1,13 +1,22 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { Bookmark, BrowserSyncData, BrowserSyncFlow, SignerMetaData, SignerMetaData_VaultSnapshot } from './types'; +import { Bookmark, EncryptedVault, SyncFlow, ExtensionSettings, VaultSnapshot } from './types'; import { v4 as uuidv4 } from 'uuid'; +/** + * Handler for extension settings stored outside the encrypted vault. + * This includes sync preferences, backups, reckless mode, whitelisted hosts, etc. + */ export abstract class SignerMetaHandler { - get signerMetaData(): SignerMetaData | undefined { - return this.#signerMetaData; + get extensionSettings(): ExtensionSettings | undefined { + return this.#extensionSettings; } - #signerMetaData?: SignerMetaData; + /** @deprecated Use extensionSettings instead */ + get signerMetaData(): ExtensionSettings | undefined { + return this.#extensionSettings; + } + + #extensionSettings?: ExtensionSettings; readonly metaProperties = ['syncFlow', 'vaultSnapshots', 'maxBackups', 'recklessMode', 'whitelistedHosts', 'bookmarks', 'devMode']; readonly DEFAULT_MAX_BACKUPS = 5; @@ -20,25 +29,30 @@ export abstract class SignerMetaHandler { */ abstract loadFullData(): Promise>>; - setFullData(data: SignerMetaData) { - this.#signerMetaData = data; + setFullData(data: ExtensionSettings) { + this.#extensionSettings = data; } - abstract saveFullData(data: SignerMetaData): Promise; + abstract saveFullData(data: ExtensionSettings): Promise; /** - * Sets the browser sync flow for the user and immediately saves it. + * Sets the sync flow preference for the user and immediately saves it. */ - async setBrowserSyncFlow(flow: BrowserSyncFlow): Promise { - if (!this.#signerMetaData) { - this.#signerMetaData = { + async setSyncFlow(flow: SyncFlow): Promise { + if (!this.#extensionSettings) { + this.#extensionSettings = { syncFlow: flow, }; } else { - this.#signerMetaData.syncFlow = flow; + this.#extensionSettings.syncFlow = flow; } - await this.saveFullData(this.#signerMetaData); + await this.saveFullData(this.#extensionSettings); + } + + /** @deprecated Use setSyncFlow instead */ + async setBrowserSyncFlow(flow: SyncFlow): Promise { + return this.setSyncFlow(flow); } abstract clearData(keep: string[]): Promise; @@ -47,93 +61,93 @@ export abstract class SignerMetaHandler { * Sets the reckless mode and immediately saves it. */ async setRecklessMode(enabled: boolean): Promise { - if (!this.#signerMetaData) { - this.#signerMetaData = { + if (!this.#extensionSettings) { + this.#extensionSettings = { recklessMode: enabled, }; } else { - this.#signerMetaData.recklessMode = enabled; + this.#extensionSettings.recklessMode = enabled; } - await this.saveFullData(this.#signerMetaData); + await this.saveFullData(this.#extensionSettings); } /** * Sets dev mode and immediately saves it. */ async setDevMode(enabled: boolean): Promise { - if (!this.#signerMetaData) { - this.#signerMetaData = { + if (!this.#extensionSettings) { + this.#extensionSettings = { devMode: enabled, }; } else { - this.#signerMetaData.devMode = enabled; + this.#extensionSettings.devMode = enabled; } - await this.saveFullData(this.#signerMetaData); + await this.saveFullData(this.#extensionSettings); } /** * Adds a host to the whitelist and immediately saves it. */ async addWhitelistedHost(host: string): Promise { - if (!this.#signerMetaData) { - this.#signerMetaData = { + if (!this.#extensionSettings) { + this.#extensionSettings = { whitelistedHosts: [host], }; } else { - const hosts = this.#signerMetaData.whitelistedHosts ?? []; + const hosts = this.#extensionSettings.whitelistedHosts ?? []; if (!hosts.includes(host)) { hosts.push(host); - this.#signerMetaData.whitelistedHosts = hosts; + this.#extensionSettings.whitelistedHosts = hosts; } } - await this.saveFullData(this.#signerMetaData); + await this.saveFullData(this.#extensionSettings); } /** * Removes a host from the whitelist and immediately saves it. */ async removeWhitelistedHost(host: string): Promise { - if (!this.#signerMetaData?.whitelistedHosts) { + if (!this.#extensionSettings?.whitelistedHosts) { return; } - this.#signerMetaData.whitelistedHosts = this.#signerMetaData.whitelistedHosts.filter( + this.#extensionSettings.whitelistedHosts = this.#extensionSettings.whitelistedHosts.filter( (h) => h !== host ); - await this.saveFullData(this.#signerMetaData); + await this.saveFullData(this.#extensionSettings); } /** * Sets the bookmarks array and immediately saves it. */ async setBookmarks(bookmarks: Bookmark[]): Promise { - if (!this.#signerMetaData) { - this.#signerMetaData = { + if (!this.#extensionSettings) { + this.#extensionSettings = { bookmarks, }; } else { - this.#signerMetaData.bookmarks = bookmarks; + this.#extensionSettings.bookmarks = bookmarks; } - await this.saveFullData(this.#signerMetaData); + await this.saveFullData(this.#extensionSettings); } /** * Gets the current bookmarks. */ getBookmarks(): Bookmark[] { - return this.#signerMetaData?.bookmarks ?? []; + return this.#extensionSettings?.bookmarks ?? []; } /** * Gets the maximum number of backups to keep. */ getMaxBackups(): number { - return this.#signerMetaData?.maxBackups ?? this.DEFAULT_MAX_BACKUPS; + return this.#extensionSettings?.maxBackups ?? this.DEFAULT_MAX_BACKUPS; } /** @@ -141,22 +155,22 @@ export abstract class SignerMetaHandler { */ async setMaxBackups(count: number): Promise { const clampedCount = Math.max(1, Math.min(20, count)); // Clamp between 1-20 - if (!this.#signerMetaData) { - this.#signerMetaData = { + if (!this.#extensionSettings) { + this.#extensionSettings = { maxBackups: clampedCount, }; } else { - this.#signerMetaData.maxBackups = clampedCount; + this.#extensionSettings.maxBackups = clampedCount; } - await this.saveFullData(this.#signerMetaData); + await this.saveFullData(this.#extensionSettings); } /** * Gets all vault backups, sorted newest first. */ - getBackups(): SignerMetaData_VaultSnapshot[] { - const backups = this.#signerMetaData?.vaultSnapshots ?? []; + getBackups(): VaultSnapshot[] { + const backups = this.#extensionSettings?.vaultSnapshots ?? []; return [...backups].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() ); @@ -165,8 +179,8 @@ export abstract class SignerMetaHandler { /** * Gets a specific backup by ID. */ - getBackupById(id: string): SignerMetaData_VaultSnapshot | undefined { - return this.#signerMetaData?.vaultSnapshots?.find(b => b.id === id); + getBackupById(id: string): VaultSnapshot | undefined { + return this.#extensionSettings?.vaultSnapshots?.find(b => b.id === id); } /** @@ -174,28 +188,28 @@ export abstract class SignerMetaHandler { * Automatically removes old backups if exceeding maxBackups. */ async createBackup( - browserSyncData: BrowserSyncData, + encryptedVault: EncryptedVault, reason: 'manual' | 'auto' | 'pre-restore' = 'manual' - ): Promise { + ): Promise { const now = new Date(); const dateTimeString = now.toISOString().replace(/[:.]/g, '-').slice(0, 19); - const identityCount = browserSyncData.identities?.length ?? 0; + const identityCount = encryptedVault.identities?.length ?? 0; - const snapshot: SignerMetaData_VaultSnapshot = { + const snapshot: VaultSnapshot = { id: uuidv4(), fileName: `Vault Backup - ${dateTimeString}`, createdAt: now.toISOString(), - data: JSON.parse(JSON.stringify(browserSyncData)), // Deep clone + data: JSON.parse(JSON.stringify(encryptedVault)), // Deep clone identityCount, reason, }; - if (!this.#signerMetaData) { - this.#signerMetaData = { + if (!this.#extensionSettings) { + this.#extensionSettings = { vaultSnapshots: [snapshot], }; } else { - const existingBackups = this.#signerMetaData.vaultSnapshots ?? []; + const existingBackups = this.#extensionSettings.vaultSnapshots ?? []; existingBackups.push(snapshot); // Enforce max backups limit (only for auto backups, keep manual and pre-restore) @@ -209,10 +223,10 @@ export abstract class SignerMetaHandler { ); const trimmedAutoBackups = autoBackups.slice(0, maxBackups); - this.#signerMetaData.vaultSnapshots = [...otherBackups, ...trimmedAutoBackups]; + this.#extensionSettings.vaultSnapshots = [...otherBackups, ...trimmedAutoBackups]; } - await this.saveFullData(this.#signerMetaData); + await this.saveFullData(this.#extensionSettings); return snapshot; } @@ -220,17 +234,17 @@ export abstract class SignerMetaHandler { * Deletes a backup by ID. */ async deleteBackup(backupId: string): Promise { - if (!this.#signerMetaData?.vaultSnapshots) { + if (!this.#extensionSettings?.vaultSnapshots) { return false; } - const initialLength = this.#signerMetaData.vaultSnapshots.length; - this.#signerMetaData.vaultSnapshots = this.#signerMetaData.vaultSnapshots.filter( + const initialLength = this.#extensionSettings.vaultSnapshots.length; + this.#extensionSettings.vaultSnapshots = this.#extensionSettings.vaultSnapshots.filter( b => b.id !== backupId ); - if (this.#signerMetaData.vaultSnapshots.length < initialLength) { - await this.saveFullData(this.#signerMetaData); + if (this.#extensionSettings.vaultSnapshots.length < initialLength) { + await this.saveFullData(this.#extensionSettings); return true; } return false; @@ -240,7 +254,7 @@ export abstract class SignerMetaHandler { * Gets the data from a backup for restoration. * Note: The caller should create a pre-restore backup before calling this. */ - getBackupData(backupId: string): BrowserSyncData | undefined { + getBackupData(backupId: string): EncryptedVault | undefined { const backup = this.getBackupById(backupId); return backup?.data; } diff --git a/projects/common/src/lib/services/storage/storage.service.ts b/projects/common/src/lib/services/storage/storage.service.ts index b327d5e..eb5ec9d 100644 --- a/projects/common/src/lib/services/storage/storage.service.ts +++ b/projects/common/src/lib/services/storage/storage.service.ts @@ -3,11 +3,13 @@ import { Injectable } from '@angular/core'; import { BrowserSyncHandler } from './browser-sync-handler'; import { BrowserSessionHandler } from './browser-session-handler'; import { - BrowserSessionData, - BrowserSyncData, - BrowserSyncFlow, - SignerMetaData, - Relay_DECRYPTED, + VaultSession, + EncryptedVault, + SyncFlow, + ExtensionSettings, + RelayData, + CashuMintRecord, + CashuProof, } from './types'; import { SignerMetaHandler } from './signer-meta-handler'; import { CryptoHelper } from '@common'; @@ -30,7 +32,6 @@ import { deleteCashuMint, updateCashuMintProofs, } from './related/cashu'; -import { CashuMint_DECRYPTED, CashuProof } from './types'; export interface StorageServiceConfig { browserSessionHandler: BrowserSessionHandler; @@ -62,13 +63,13 @@ export class StorageService { this.isInitialized = true; } - async enableBrowserSyncFlow(flow: BrowserSyncFlow): Promise { + async enableBrowserSyncFlow(flow: SyncFlow): Promise { this.assureIsInitialized(); - this.#signerMetaHandler.setBrowserSyncFlow(flow); + this.#signerMetaHandler.setSyncFlow(flow); } - async loadSignerMetaData(): Promise { + async loadExtensionSettings(): Promise { this.assureIsInitialized(); const data = await this.#signerMetaHandler.loadFullData(); @@ -77,11 +78,16 @@ export class StorageService { return undefined; } - this.#signerMetaHandler.setFullData(data as SignerMetaData); - return data as SignerMetaData; + this.#signerMetaHandler.setFullData(data as ExtensionSettings); + return data as ExtensionSettings; } - async loadBrowserSessionData(): Promise { + /** @deprecated Use loadExtensionSettings instead */ + async loadSignerMetaData(): Promise { + return this.loadExtensionSettings(); + } + + async loadVaultSession(): Promise { this.assureIsInitialized(); const data = await this.#browserSessionHandler.loadFullData(); @@ -91,22 +97,27 @@ export class StorageService { } // Set the existing data for in-memory usage. - this.#browserSessionHandler.setFullData(data as BrowserSessionData); - return data as BrowserSessionData; + this.#browserSessionHandler.setFullData(data as VaultSession); + return data as VaultSession; + } + + /** @deprecated Use loadVaultSession instead */ + async loadBrowserSessionData(): Promise { + return this.loadVaultSession(); } /** - * Load and migrate the browser sync data. If no data is available yet, + * Load and migrate the encrypted vault data. If no data is available yet, * the returned object is undefined. */ - async loadAndMigrateBrowserSyncData(): Promise { + async loadAndMigrateEncryptedVault(): Promise { this.assureIsInitialized(); - const unmigratedBrowserSyncData = + const unmigratedEncryptedVault = await this.getBrowserSyncHandler().loadUnmigratedData(); - const { browserSyncData, migrationWasPerformed } = - this.#migrateBrowserSyncData(unmigratedBrowserSyncData); + const { encryptedVault, migrationWasPerformed } = + this.#migrateEncryptedVault(unmigratedEncryptedVault); - if (!browserSyncData) { + if (!encryptedVault) { // Nothing to do at this point. return undefined; } @@ -114,13 +125,18 @@ export class StorageService { // There is data. Check, if it was migrated. if (migrationWasPerformed) { // Persist the migrated data back to the browser sync storage. - this.getBrowserSyncHandler().saveAndSetFullData(browserSyncData); + this.getBrowserSyncHandler().saveAndSetFullData(encryptedVault); } else { // Set the data for in-memory usage. - this.getBrowserSyncHandler().setFullData(browserSyncData); + this.getBrowserSyncHandler().setFullData(encryptedVault); } - return browserSyncData; + return encryptedVault; + } + + /** @deprecated Use loadAndMigrateEncryptedVault instead */ + async loadAndMigrateBrowserSyncData(): Promise { + return this.loadAndMigrateEncryptedVault(); } async deleteVault(doNotSetIsInitializedToFalse = false) { @@ -183,7 +199,7 @@ export class StorageService { await deleteRelay.call(this, relayId); } - async updateRelay(relayClone: Relay_DECRYPTED): Promise { + async updateRelay(relayClone: RelayData): Promise { await updateRelay.call(this, relayClone); } @@ -209,7 +225,7 @@ export class StorageService { name: string; mintUrl: string; unit?: string; - }): Promise { + }): Promise { return await addCashuMint.call(this, data); } @@ -227,36 +243,36 @@ export class StorageService { exportVault(): string { this.assureIsInitialized(); const vaultJson = JSON.stringify( - this.getBrowserSyncHandler().browserSyncData, + this.getBrowserSyncHandler().encryptedVault, undefined, 4 ); return vaultJson; } - async importVault(allegedBrowserSyncData: BrowserSyncData) { + async importVault(allegedEncryptedVault: EncryptedVault) { this.assureIsInitialized(); - const isValidData = this.#allegedBrowserSyncDataIsValid( - allegedBrowserSyncData + const isValidData = this.#allegedEncryptedVaultIsValid( + allegedEncryptedVault ); if (!isValidData) { throw new Error('The imported data is not valid.'); } await this.getBrowserSyncHandler().saveAndSetFullData( - allegedBrowserSyncData + allegedEncryptedVault ); } getBrowserSyncHandler(): BrowserSyncHandler { this.assureIsInitialized(); - switch (this.#signerMetaHandler.signerMetaData?.syncFlow) { - case BrowserSyncFlow.NO_SYNC: + switch (this.#signerMetaHandler.extensionSettings?.syncFlow) { + case SyncFlow.NO_SYNC: return this.#browserSyncNoHandler; - case BrowserSyncFlow.BROWSER_SYNC: + case SyncFlow.BROWSER_SYNC: default: return this.#browserSyncYesHandler; } @@ -275,14 +291,14 @@ export class StorageService { } /** - * Get the current browser sync flow setting. + * Get the current sync flow setting. * Returns NO_SYNC if not initialized or no setting found. */ - getSyncFlow(): BrowserSyncFlow { - if (!this.isInitialized || !this.#signerMetaHandler?.signerMetaData) { - return BrowserSyncFlow.NO_SYNC; + getSyncFlow(): SyncFlow { + if (!this.isInitialized || !this.#signerMetaHandler?.extensionSettings) { + return SyncFlow.NO_SYNC; } - return this.#signerMetaHandler.signerMetaData.syncFlow ?? BrowserSyncFlow.NO_SYNC; + return this.#signerMetaHandler.extensionSettings.syncFlow ?? SyncFlow.NO_SYNC; } /** @@ -297,25 +313,24 @@ export class StorageService { } async encrypt(value: string): Promise { - const browserSessionData = - this.getBrowserSessionHandler().browserSessionData; - if (!browserSessionData) { - throw new Error('Browser session data is undefined.'); + const vaultSession = this.getBrowserSessionHandler().vaultSession; + if (!vaultSession) { + throw new Error('Vault session is undefined.'); } // v2: Use pre-derived key directly with AES-GCM - if (browserSessionData.vaultKey) { - return this.encryptV2(value, browserSessionData.iv, browserSessionData.vaultKey); + if (vaultSession.vaultKey) { + return this.encryptV2(value, vaultSession.iv, vaultSession.vaultKey); } // v1: Use PBKDF2 with password - if (!browserSessionData.vaultPassword) { + if (!vaultSession.vaultPassword) { throw new Error('No vault password or key available.'); } return CryptoHelper.encrypt( value, - browserSessionData.iv, - browserSessionData.vaultPassword + vaultSession.iv, + vaultSession.vaultPassword ); } @@ -347,31 +362,30 @@ export class StorageService { value: string, returnType: 'string' | 'number' | 'boolean' ): Promise { - const browserSessionData = - this.getBrowserSessionHandler().browserSessionData; - if (!browserSessionData) { - throw new Error('Browser session data is undefined.'); + const vaultSession = this.getBrowserSessionHandler().vaultSession; + if (!vaultSession) { + throw new Error('Vault session is undefined.'); } // v2: Use pre-derived key directly with AES-GCM - if (browserSessionData.vaultKey) { + if (vaultSession.vaultKey) { const decryptedValue = await this.decryptV2( value, - browserSessionData.iv, - browserSessionData.vaultKey + vaultSession.iv, + vaultSession.vaultKey ); return this.parseDecryptedValue(decryptedValue, returnType); } // v1: Use PBKDF2 with password - if (!browserSessionData.vaultPassword) { + if (!vaultSession.vaultPassword) { throw new Error('No vault password or key available.'); } return this.decryptWithLockedVault( value, returnType, - browserSessionData.iv, - browserSessionData.vaultPassword + vaultSession.iv, + vaultSession.vaultPassword ); } @@ -445,28 +459,28 @@ export class StorageService { } /** - * Migrate the browser sync data to the latest version. + * Migrate the encrypted vault to the latest version. */ - #migrateBrowserSyncData(browserSyncData: Partial>): { - browserSyncData?: BrowserSyncData; + #migrateEncryptedVault(encryptedVault: Partial>): { + encryptedVault?: EncryptedVault; migrationWasPerformed: boolean; } { - if (Object.keys(browserSyncData).length === 0) { - // First run. There is no browser sync data yet. + if (Object.keys(encryptedVault).length === 0) { + // First run. There is no encrypted vault yet. return { - browserSyncData: undefined, + encryptedVault: undefined, migrationWasPerformed: false, }; } // Will be implemented if migration is required. return { - browserSyncData: browserSyncData as BrowserSyncData, + encryptedVault: encryptedVault as EncryptedVault, migrationWasPerformed: false, }; } - #allegedBrowserSyncDataIsValid(data: BrowserSyncData): boolean { + #allegedEncryptedVaultIsValid(data: EncryptedVault): boolean { if (typeof data.iv === 'undefined') { return false; } diff --git a/projects/common/src/lib/services/storage/types.ts b/projects/common/src/lib/services/storage/types.ts index 00c3db4..4146a1d 100644 --- a/projects/common/src/lib/services/storage/types.ts +++ b/projects/common/src/lib/services/storage/types.ts @@ -1,15 +1,14 @@ import { ExtensionMethod, Nip07MethodPolicy } from '@common'; -export interface Permission_DECRYPTED { - id: string; - identityId: string; - host: string; - method: ExtensionMethod; - methodPolicy: Nip07MethodPolicy; - kind?: number; -} +// ============================================================================= +// STORAGE DATA TRANSFER OBJECTS (DTOs) +// These types represent data as stored in browser storage +// ============================================================================= -export interface Permission_ENCRYPTED { +/** + * Permission as stored in encrypted vault (encrypted string fields) + */ +export interface StoredPermission { id: string; identityId: string; host: string; @@ -18,24 +17,37 @@ export interface Permission_ENCRYPTED { kind?: string; } -export interface Identity_DECRYPTED { +/** + * Permission in session memory (typed fields) + */ +export interface PermissionData { + id: string; + identityId: string; + host: string; + method: ExtensionMethod; + methodPolicy: Nip07MethodPolicy; + kind?: number; +} + +/** + * Identity as stored in encrypted vault + */ +export interface StoredIdentity { id: string; createdAt: string; nick: string; privkey: string; } -export type Identity_ENCRYPTED = Identity_DECRYPTED; +/** + * Identity in session memory (same structure, just semantic clarity) + */ +export type IdentityData = StoredIdentity; -export interface Relay_DECRYPTED { - id: string; - identityId: string; - url: string; - read: boolean; - write: boolean; -} - -export interface Relay_ENCRYPTED { +/** + * Relay as stored in encrypted vault (encrypted boolean fields) + */ +export interface StoredRelay { id: string; identityId: string; url: string; @@ -44,10 +56,21 @@ export interface Relay_ENCRYPTED { } /** - * NWC (Nostr Wallet Connect) connection - Decrypted + * Relay in session memory (typed boolean fields) + */ +export interface RelayData { + id: string; + identityId: string; + url: string; + read: boolean; + write: boolean; +} + +/** + * NWC (Nostr Wallet Connect) connection in session memory * Stores NIP-47 wallet connection data */ -export interface NwcConnection_DECRYPTED { +export interface NwcConnectionRecord { id: string; name: string; // User-defined wallet name connectionUrl: string; // Full nostr+walletconnect:// URL @@ -61,9 +84,9 @@ export interface NwcConnection_DECRYPTED { } /** - * NWC connection - Encrypted for storage + * NWC connection as stored in encrypted vault */ -export interface NwcConnection_ENCRYPTED { +export interface StoredNwcConnection { id: string; name: string; connectionUrl: string; @@ -89,10 +112,10 @@ export interface CashuProof { } /** - * Cashu Mint Connection - Decrypted + * Cashu Mint Connection in session memory * Stores NIP-60 Cashu mint connection data with local proofs */ -export interface CashuMint_DECRYPTED { +export interface CashuMintRecord { id: string; name: string; // User-defined mint name mintUrl: string; // Mint API URL @@ -104,9 +127,9 @@ export interface CashuMint_DECRYPTED { } /** - * Cashu Mint Connection - Encrypted for storage + * Cashu Mint Connection as stored in encrypted vault */ -export interface CashuMint_ENCRYPTED { +export interface StoredCashuMint { id: string; name: string; mintUrl: string; @@ -117,7 +140,15 @@ export interface CashuMint_ENCRYPTED { cachedBalanceAt?: string; } -export interface BrowserSyncData_PART_Unencrypted { +// ============================================================================= +// ENCRYPTED VAULT +// The vault is the encrypted container holding all sensitive data +// ============================================================================= + +/** + * Vault header - unencrypted metadata needed to decrypt the vault + */ +export interface EncryptedVaultHeader { version: number; iv: string; vaultHash: string; @@ -126,26 +157,42 @@ export interface BrowserSyncData_PART_Unencrypted { salt?: string; } -export interface BrowserSyncData_PART_Encrypted { +/** + * Vault content - encrypted payload containing all sensitive data + */ +export interface EncryptedVaultContent { selectedIdentityId: string | null; - permissions: Permission_ENCRYPTED[]; - identities: Identity_ENCRYPTED[]; - relays: Relay_ENCRYPTED[]; - nwcConnections?: NwcConnection_ENCRYPTED[]; - cashuMints?: CashuMint_ENCRYPTED[]; + permissions: StoredPermission[]; + identities: StoredIdentity[]; + relays: StoredRelay[]; + nwcConnections?: StoredNwcConnection[]; + cashuMints?: StoredCashuMint[]; } -export type BrowserSyncData = BrowserSyncData_PART_Unencrypted & - BrowserSyncData_PART_Encrypted; +/** + * Complete encrypted vault as stored in browser sync storage + */ +export type EncryptedVault = EncryptedVaultHeader & EncryptedVaultContent; -export enum BrowserSyncFlow { +/** + * Sync flow preference for vault data + */ +export enum SyncFlow { NO_SYNC = 0, BROWSER_SYNC = 1, SIGNER_SYNC = 2, CUSTOM_SYNC = 3, } -export interface BrowserSessionData { +// ============================================================================= +// VAULT SESSION +// Runtime state when vault is unlocked +// ============================================================================= + +/** + * Vault session - decrypted vault data in session memory + */ +export interface VaultSession { // The following properties purely come from the browser session storage // and will never be going into the browser sync storage. vaultPassword?: string; // v1 only: raw password for PBKDF2 @@ -155,24 +202,32 @@ export interface BrowserSessionData { iv: string; // Version 2+: Random salt for Argon2id (base64) salt?: string; - permissions: Permission_DECRYPTED[]; - identities: Identity_DECRYPTED[]; + permissions: PermissionData[]; + identities: IdentityData[]; selectedIdentityId: string | null; - relays: Relay_DECRYPTED[]; - nwcConnections?: NwcConnection_DECRYPTED[]; - cashuMints?: CashuMint_DECRYPTED[]; + relays: RelayData[]; + nwcConnections?: NwcConnectionRecord[]; + cashuMints?: CashuMintRecord[]; } -export interface SignerMetaData_VaultSnapshot { +// ============================================================================= +// EXTENSION SETTINGS +// Non-vault configuration stored separately +// ============================================================================= + +/** + * Vault snapshot for backup/restore + */ +export interface VaultSnapshot { id: string; fileName: string; createdAt: string; // ISO timestamp - data: BrowserSyncData; + data: EncryptedVault; identityCount: number; reason?: 'manual' | 'auto' | 'pre-restore'; // Why was this backup created } -export const SIGNER_META_DATA_KEY = { +export const EXTENSION_SETTINGS_KEYS = { vaultSnapshots: 'vaultSnapshots', }; @@ -186,10 +241,13 @@ export interface Bookmark { createdAt: number; } -export interface SignerMetaData { - syncFlow?: number; // 0 = no sync, 1 = browser sync, (future: 2 = Signer sync, 3 = Custom sync (bring your own sync)) +/** + * Extension settings - non-vault configuration + */ +export interface ExtensionSettings { + syncFlow?: number; // 0 = no sync, 1 = browser sync, (future: 2 = Signer sync, 3 = Custom sync) - vaultSnapshots?: SignerMetaData_VaultSnapshot[]; + vaultSnapshots?: VaultSnapshot[]; // Maximum number of automatic backups to keep (default: 5) maxBackups?: number; @@ -229,3 +287,47 @@ export interface ProfileMetadata { * Cache for profile metadata, stored in session storage */ export type ProfileMetadataCache = Record; + +// ============================================================================= +// BACKWARDS COMPATIBILITY ALIASES +// These will be removed in a future version +// ============================================================================= + +/** @deprecated Use StoredPermission instead */ +export type Permission_ENCRYPTED = StoredPermission; +/** @deprecated Use PermissionData instead */ +export type Permission_DECRYPTED = PermissionData; +/** @deprecated Use StoredIdentity instead */ +export type Identity_ENCRYPTED = StoredIdentity; +/** @deprecated Use IdentityData instead */ +export type Identity_DECRYPTED = IdentityData; +/** @deprecated Use StoredRelay instead */ +export type Relay_ENCRYPTED = StoredRelay; +/** @deprecated Use RelayData instead */ +export type Relay_DECRYPTED = RelayData; +/** @deprecated Use StoredNwcConnection instead */ +export type NwcConnection_ENCRYPTED = StoredNwcConnection; +/** @deprecated Use NwcConnectionRecord instead */ +export type NwcConnection_DECRYPTED = NwcConnectionRecord; +/** @deprecated Use StoredCashuMint instead */ +export type CashuMint_ENCRYPTED = StoredCashuMint; +/** @deprecated Use CashuMintRecord instead */ +export type CashuMint_DECRYPTED = CashuMintRecord; +/** @deprecated Use EncryptedVaultHeader instead */ +export type BrowserSyncData_PART_Unencrypted = EncryptedVaultHeader; +/** @deprecated Use EncryptedVaultContent instead */ +export type BrowserSyncData_PART_Encrypted = EncryptedVaultContent; +/** @deprecated Use EncryptedVault instead */ +export type BrowserSyncData = EncryptedVault; +/** @deprecated Use SyncFlow instead */ +export const BrowserSyncFlow = SyncFlow; +/** @deprecated Use SyncFlow instead */ +export type BrowserSyncFlow = SyncFlow; +/** @deprecated Use VaultSession instead */ +export type BrowserSessionData = VaultSession; +/** @deprecated Use VaultSnapshot instead */ +export type SignerMetaData_VaultSnapshot = VaultSnapshot; +/** @deprecated Use EXTENSION_SETTINGS_KEYS instead */ +export const SIGNER_META_DATA_KEY = EXTENSION_SETTINGS_KEYS; +/** @deprecated Use ExtensionSettings instead */ +export type SignerMetaData = ExtensionSettings; diff --git a/projects/common/src/lib/styles/_typography.scss b/projects/common/src/lib/styles/_typography.scss index 9e474b9..0523a42 100644 --- a/projects/common/src/lib/styles/_typography.scss +++ b/projects/common/src/lib/styles/_typography.scss @@ -1,5 +1,6 @@ -.sam-text-muted { - color: var(--muted-foreground); +.sam-text-muted, +.text-muted { + color: var(--muted-foreground) !important; } .sam-text-lg { diff --git a/projects/common/src/public-api.ts b/projects/common/src/public-api.ts index bf5e1ff..43714ed 100644 --- a/projects/common/src/public-api.ts +++ b/projects/common/src/public-api.ts @@ -2,11 +2,18 @@ * Public API Surface of common */ +// Domain (DDD Value Objects & Repository Interfaces) +export * from './lib/domain'; + +// Infrastructure (Encryption & Repository Implementations) +export * from './lib/infrastructure'; + // Common export * from './lib/common/nav-component'; // Constants export * from './lib/constants/fallback-relays'; +export * from './lib/constants/event-kinds'; // Helpers export * from './lib/helpers/crypto-helper'; diff --git a/projects/firefox/src/app/common/data/firefox-meta-handler.ts b/projects/firefox/src/app/common/data/firefox-meta-handler.ts index 5fa8ad9..437531b 100644 --- a/projects/firefox/src/app/common/data/firefox-meta-handler.ts +++ b/projects/firefox/src/app/common/data/firefox-meta-handler.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { SignerMetaData, SignerMetaHandler } from '@common'; +import { ExtensionSettings, SignerMetaHandler } from '@common'; import browser from 'webextension-polyfill'; export class FirefoxMetaHandler extends SignerMetaHandler { @@ -20,7 +20,7 @@ export class FirefoxMetaHandler extends SignerMetaHandler { return data; } - async saveFullData(data: SignerMetaData): Promise { + async saveFullData(data: ExtensionSettings): Promise { await browser.storage.local.set(data as Record); console.log(data); } diff --git a/projects/firefox/src/app/common/data/firefox-session-handler.ts b/projects/firefox/src/app/common/data/firefox-session-handler.ts index 8efc491..fa73014 100644 --- a/projects/firefox/src/app/common/data/firefox-session-handler.ts +++ b/projects/firefox/src/app/common/data/firefox-session-handler.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { BrowserSessionData, BrowserSessionHandler } from '@common'; +import { VaultSession, BrowserSessionHandler } from '@common'; import browser from 'webextension-polyfill'; export class FirefoxSessionHandler extends BrowserSessionHandler { @@ -7,7 +7,7 @@ export class FirefoxSessionHandler extends BrowserSessionHandler { return browser.storage.session.get(null); } - async saveFullData(data: BrowserSessionData): Promise { + async saveFullData(data: VaultSession): Promise { await browser.storage.session.set(data as Record); } diff --git a/projects/firefox/src/app/common/data/firefox-sync-no-handler.ts b/projects/firefox/src/app/common/data/firefox-sync-no-handler.ts index a7bbf33..05ee642 100644 --- a/projects/firefox/src/app/common/data/firefox-sync-no-handler.ts +++ b/projects/firefox/src/app/common/data/firefox-sync-no-handler.ts @@ -1,12 +1,12 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { - BrowserSyncData, - CashuMint_ENCRYPTED, - Identity_ENCRYPTED, - NwcConnection_ENCRYPTED, - Permission_ENCRYPTED, + EncryptedVault, + StoredCashuMint, + StoredIdentity, + StoredNwcConnection, + StoredPermission, BrowserSyncHandler, - Relay_ENCRYPTED, + StoredRelay, } from '@common'; import browser from 'webextension-polyfill'; @@ -25,20 +25,20 @@ export class FirefoxSyncNoHandler extends BrowserSyncHandler { return data; } - async saveAndSetFullData(data: BrowserSyncData): Promise { + async saveAndSetFullData(data: EncryptedVault): Promise { await browser.storage.local.set(data as Record); this.setFullData(data); } async saveAndSetPartialData_Permissions(data: { - permissions: Permission_ENCRYPTED[]; + permissions: StoredPermission[]; }): Promise { await browser.storage.local.set(data); this.setPartialData_Permissions(data); } async saveAndSetPartialData_Identities(data: { - identities: Identity_ENCRYPTED[]; + identities: StoredIdentity[]; }): Promise { await browser.storage.local.set(data); this.setPartialData_Identities(data); @@ -52,21 +52,21 @@ export class FirefoxSyncNoHandler extends BrowserSyncHandler { } async saveAndSetPartialData_Relays(data: { - relays: Relay_ENCRYPTED[]; + relays: StoredRelay[]; }): Promise { await browser.storage.local.set(data); this.setPartialData_Relays(data); } async saveAndSetPartialData_NwcConnections(data: { - nwcConnections: NwcConnection_ENCRYPTED[]; + nwcConnections: StoredNwcConnection[]; }): Promise { await browser.storage.local.set(data); this.setPartialData_NwcConnections(data); } async saveAndSetPartialData_CashuMints(data: { - cashuMints: CashuMint_ENCRYPTED[]; + cashuMints: StoredCashuMint[]; }): Promise { await browser.storage.local.set(data); this.setPartialData_CashuMints(data); diff --git a/projects/firefox/src/app/common/data/firefox-sync-yes-handler.ts b/projects/firefox/src/app/common/data/firefox-sync-yes-handler.ts index 98321d1..c556f4d 100644 --- a/projects/firefox/src/app/common/data/firefox-sync-yes-handler.ts +++ b/projects/firefox/src/app/common/data/firefox-sync-yes-handler.ts @@ -1,12 +1,12 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { - BrowserSyncData, - CashuMint_ENCRYPTED, - Identity_ENCRYPTED, - NwcConnection_ENCRYPTED, - Permission_ENCRYPTED, + EncryptedVault, + StoredCashuMint, + StoredIdentity, + StoredNwcConnection, + StoredPermission, BrowserSyncHandler, - Relay_ENCRYPTED, + StoredRelay, } from '@common'; import browser from 'webextension-polyfill'; @@ -19,20 +19,20 @@ export class FirefoxSyncYesHandler extends BrowserSyncHandler { return await browser.storage.sync.get(null); } - async saveAndSetFullData(data: BrowserSyncData): Promise { + async saveAndSetFullData(data: EncryptedVault): Promise { await browser.storage.sync.set(data as Record); this.setFullData(data); } async saveAndSetPartialData_Permissions(data: { - permissions: Permission_ENCRYPTED[]; + permissions: StoredPermission[]; }): Promise { await browser.storage.sync.set(data); this.setPartialData_Permissions(data); } async saveAndSetPartialData_Identities(data: { - identities: Identity_ENCRYPTED[]; + identities: StoredIdentity[]; }): Promise { await browser.storage.sync.set(data); this.setPartialData_Identities(data); @@ -46,21 +46,21 @@ export class FirefoxSyncYesHandler extends BrowserSyncHandler { } async saveAndSetPartialData_Relays(data: { - relays: Relay_ENCRYPTED[]; + relays: StoredRelay[]; }): Promise { await browser.storage.sync.set(data); this.setPartialData_Relays(data); } async saveAndSetPartialData_NwcConnections(data: { - nwcConnections: NwcConnection_ENCRYPTED[]; + nwcConnections: StoredNwcConnection[]; }): Promise { await browser.storage.sync.set(data); this.setPartialData_NwcConnections(data); } async saveAndSetPartialData_CashuMints(data: { - cashuMints: CashuMint_ENCRYPTED[]; + cashuMints: StoredCashuMint[]; }): Promise { await browser.storage.sync.set(data); this.setPartialData_CashuMints(data); diff --git a/projects/firefox/src/app/components/edit-identity/permissions/permissions.component.html b/projects/firefox/src/app/components/edit-identity/permissions/permissions.component.html index d4704d0..32b09d7 100644 --- a/projects/firefox/src/app/components/edit-identity/permissions/permissions.component.html +++ b/projects/firefox/src/app/components/edit-identity/permissions/permissions.component.html @@ -30,7 +30,7 @@ > {{ permission.method }} @if(typeof permission.kind !== 'undefined') { - (kind {{ permission.kind }}) + (kind {{ permission.kind }}) }