diff --git a/demos/passkeys-demo/src/hooks/useLNC.ts b/demos/passkeys-demo/src/hooks/useLNC.ts index d66b57b..fa64c62 100644 --- a/demos/passkeys-demo/src/hooks/useLNC.ts +++ b/demos/passkeys-demo/src/hooks/useLNC.ts @@ -2,7 +2,8 @@ import { useCallback } from 'react'; import LNC from '@lightninglabs/lnc-web'; // create a singleton instance of LNC that will live for the lifetime of the app -const lnc = new LNC({}); +// useUnifiedStore enables the new strategy-based authentication +const lnc = new LNC({ useUnifiedStore: true }); /** * A hook that exposes a single LNC instance of LNC to all component that need it. @@ -15,13 +16,17 @@ const useLNC = () => { await lnc.connect(); // verify we can fetch data await lnc.lnd.lightning.listChannels(); - // set the password after confirming the connection works - lnc.credentials.password = password; + // persist credentials with password encryption after confirming the connection works + await lnc.persistWithPassword(password); }, []); /** Connects to LNC using the password to decrypt the stored keys */ const login = useCallback(async (password: string) => { - lnc.credentials.password = password; + // unlock credentials using password + const unlocked = await lnc.unlock({ method: 'password', password }); + if (!unlocked) { + throw new Error('Failed to unlock credentials. Check your password.'); + } await lnc.connect(); }, []); diff --git a/lib/credentialOrchestrator.test.ts b/lib/credentialOrchestrator.test.ts new file mode 100644 index 0000000..5a00765 --- /dev/null +++ b/lib/credentialOrchestrator.test.ts @@ -0,0 +1,503 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { CredentialOrchestrator } from './credentialOrchestrator'; +import UnifiedCredentialStore from './stores/unifiedCredentialStore'; +import { CredentialStore } from './types/lnc'; +import LncCredentialStore from './util/credentialStore'; + +// Mock the log module +vi.mock('./util/log', () => ({ + log: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn() + } +})); + +describe('CredentialOrchestrator', () => { + beforeEach(() => { + localStorage.clear(); + vi.clearAllMocks(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + describe('constructor and store creation', () => { + it('should create legacy store by default', () => { + const orchestrator = new CredentialOrchestrator({}); + const store = orchestrator.getCredentialStore(); + + expect(store).toBeInstanceOf(LncCredentialStore); + }); + + it('should create UnifiedCredentialStore when useUnifiedStore is true', () => { + const orchestrator = new CredentialOrchestrator({ + useUnifiedStore: true + }); + const store = orchestrator.getCredentialStore(); + + expect(store).toBeInstanceOf(UnifiedCredentialStore); + }); + + it('should use custom credential store if provided', () => { + const customStore: CredentialStore = { + password: undefined, + pairingPhrase: '', + serverHost: '', + localKey: '', + remoteKey: '', + isPaired: false, + clear: vi.fn() + }; + + const orchestrator = new CredentialOrchestrator({ + credentialStore: customStore + }); + + expect(orchestrator.getCredentialStore()).toBe(customStore); + }); + + it('should set serverHost from config for UnifiedCredentialStore', () => { + const orchestrator = new CredentialOrchestrator({ + useUnifiedStore: true, + serverHost: 'test.server:443' + }); + const store = orchestrator.getCredentialStore(); + + expect(store.serverHost).toBe('test.server:443'); + }); + + it('should set pairingPhrase from config for UnifiedCredentialStore', () => { + const orchestrator = new CredentialOrchestrator({ + useUnifiedStore: true, + pairingPhrase: 'test-pairing-phrase' + }); + const store = orchestrator.getCredentialStore(); + + expect(store.pairingPhrase).toBe('test-pairing-phrase'); + }); + + it('should set serverHost from config for legacy store', () => { + const orchestrator = new CredentialOrchestrator({ + serverHost: 'test.server:443' + }); + const store = orchestrator.getCredentialStore(); + + expect(store.serverHost).toBe('test.server:443'); + }); + + it('should set pairingPhrase from config for legacy store', () => { + const orchestrator = new CredentialOrchestrator({ + pairingPhrase: 'test-pairing-phrase' + }); + const store = orchestrator.getCredentialStore(); + + expect(store.pairingPhrase).toBe('test-pairing-phrase'); + }); + + it('should not overwrite serverHost if already paired for UnifiedCredentialStore', () => { + // Pre-populate localStorage to simulate paired state + const namespace = 'default'; + localStorage.setItem( + `lnc-web:${namespace}:localKey`, + JSON.stringify({ + salt: 'test-salt', + cipher: 'test-cipher', + data: 'test-data' + }) + ); + + const orchestrator = new CredentialOrchestrator({ + useUnifiedStore: true, + serverHost: 'new.server:443' + }); + const store = orchestrator.getCredentialStore(); + + // Since isPaired checks strategy credentials, and we've set localKey, + // the serverHost should not be overwritten + // However, the UnifiedCredentialStore checks isPaired via hasAnyCredentials + // which looks at strategy persistence + expect(store).toBeInstanceOf(UnifiedCredentialStore); + }); + + it('should use default namespace when not provided', () => { + const orchestrator = new CredentialOrchestrator({}); + const store = orchestrator.getCredentialStore(); + + // Verify the store was created (default namespace is 'default') + expect(store).toBeDefined(); + }); + }); + + describe('unlock', () => { + it('should unlock UnifiedCredentialStore with password', async () => { + const orchestrator = new CredentialOrchestrator({ + useUnifiedStore: true, + namespace: 'test-unlock' + }); + + const result = await orchestrator.unlock({ + method: 'password', + password: 'test-password' + }); + + expect(result).toBe(true); + expect(orchestrator.isUnlocked).toBe(true); + }); + + it('should unlock legacy store with password', async () => { + const orchestrator = new CredentialOrchestrator({ + namespace: 'test-unlock-legacy' + }); + + const result = await orchestrator.unlock({ + method: 'password', + password: 'test-password' + }); + + // Legacy store returns true for unlock, but clears password after persist + expect(result).toBe(true); + // Note: Legacy store clears in-memory password after persisting, + // so isUnlocked returns false. This is expected legacy behavior. + }); + + it('should return false for legacy store unlock failure', async () => { + // Create a store that will throw on password set + const failingStore: CredentialStore = { + password: undefined, + pairingPhrase: '', + serverHost: '', + localKey: '', + remoteKey: '', + isPaired: false, + clear: vi.fn() + }; + + // Make password setter throw + Object.defineProperty(failingStore, 'password', { + get: () => undefined, + set: () => { + throw new Error('Password set failed'); + } + }); + + const orchestrator = new CredentialOrchestrator({ + credentialStore: failingStore + }); + + const result = await orchestrator.unlock({ + method: 'password', + password: 'test-password' + }); + + expect(result).toBe(false); + }); + + it('should return false for missing password in unlock options', async () => { + const orchestrator = new CredentialOrchestrator({ + namespace: 'test-no-password' + }); + + // Cast to any to test edge case + const result = await orchestrator.unlock({ + method: 'password', + password: '' + } as any); + + expect(result).toBe(false); + }); + }); + + describe('persistWithPassword', () => { + it('should persist with UnifiedCredentialStore', async () => { + const orchestrator = new CredentialOrchestrator({ + useUnifiedStore: true, + namespace: 'test-persist' + }); + + // Set some credentials first (simulating post-connection state) + const store = orchestrator.getCredentialStore(); + store.localKey = 'test-local-key'; + store.remoteKey = 'test-remote-key'; + store.serverHost = 'test.server:443'; + store.pairingPhrase = 'test-phrase'; + + await orchestrator.persistWithPassword('test-password'); + + expect(orchestrator.isUnlocked).toBe(true); + }); + + it('should persist with legacy store', async () => { + const orchestrator = new CredentialOrchestrator({ + namespace: 'test-persist-legacy' + }); + + // Set some credentials first + const store = orchestrator.getCredentialStore(); + store.localKey = 'test-local-key'; + store.remoteKey = 'test-remote-key'; + + await orchestrator.persistWithPassword('test-password'); + + // Legacy store clears in-memory password after persisting encrypted data, + // but the data should be persisted to localStorage + expect(store.isPaired).toBe(true); + // Verify data was persisted by checking localStorage + const key = 'lnc-web:test-persist-legacy'; + const persisted = JSON.parse(localStorage.getItem(key) || '{}'); + expect(persisted.cipher).toBeDefined(); + expect(persisted.salt).toBeDefined(); + }); + + it('should throw if unlock fails during persist', async () => { + const orchestrator = new CredentialOrchestrator({ + useUnifiedStore: true, + namespace: 'test-persist-fail' + }); + + // Mock unlock to fail by using a store with failing strategy + const store = orchestrator.getCredentialStore() as UnifiedCredentialStore; + vi.spyOn(store, 'unlock').mockResolvedValue(false); + + await expect( + orchestrator.persistWithPassword('test-password') + ).rejects.toThrow('Failed to unlock credentials with password'); + }); + }); + + describe('getAuthenticationInfo', () => { + it('should return auth info from UnifiedCredentialStore', async () => { + const orchestrator = new CredentialOrchestrator({ + useUnifiedStore: true, + namespace: 'test-auth-info' + }); + + const info = await orchestrator.getAuthenticationInfo(); + + expect(info).toEqual({ + isUnlocked: false, + hasStoredCredentials: false, + preferredUnlockMethod: 'password' + }); + }); + + it('should return auth info from legacy store', async () => { + const orchestrator = new CredentialOrchestrator({ + namespace: 'test-auth-info-legacy' + }); + + const info = await orchestrator.getAuthenticationInfo(); + + expect(info).toEqual({ + isUnlocked: false, + hasStoredCredentials: false, + preferredUnlockMethod: 'password' + }); + }); + + it('should show isUnlocked true after unlock', async () => { + const orchestrator = new CredentialOrchestrator({ + useUnifiedStore: true, + namespace: 'test-unlocked-info' + }); + + await orchestrator.unlock({ + method: 'password', + password: 'test-password' + }); + + const info = await orchestrator.getAuthenticationInfo(); + expect(info.isUnlocked).toBe(true); + }); + }); + + describe('isPaired', () => { + it('should return false when not paired (UnifiedCredentialStore)', () => { + const orchestrator = new CredentialOrchestrator({ + useUnifiedStore: true, + namespace: 'test-not-paired' + }); + + expect(orchestrator.isPaired).toBe(false); + }); + + it('should return false when not paired (legacy store)', () => { + const orchestrator = new CredentialOrchestrator({ + namespace: 'test-not-paired-legacy' + }); + + expect(orchestrator.isPaired).toBe(false); + }); + + it('should return true when paired (legacy store with remoteKey)', () => { + // Pre-populate localStorage to simulate paired state + const namespace = 'test-paired-legacy'; + localStorage.setItem( + `lnc-web:${namespace}`, + JSON.stringify({ + salt: 'test-salt', + cipher: 'test-cipher', + serverHost: 'test.server:443', + remoteKey: 'encrypted-remote-key', + localKey: 'encrypted-local-key', + pairingPhrase: '' + }) + ); + + const orchestrator = new CredentialOrchestrator({ + namespace + }); + + expect(orchestrator.isPaired).toBe(true); + }); + }); + + describe('isUnlocked', () => { + it('should return false initially (UnifiedCredentialStore)', () => { + const orchestrator = new CredentialOrchestrator({ + useUnifiedStore: true, + namespace: 'test-not-unlocked' + }); + + expect(orchestrator.isUnlocked).toBe(false); + }); + + it('should return false initially (legacy store)', () => { + const orchestrator = new CredentialOrchestrator({ + namespace: 'test-not-unlocked-legacy' + }); + + expect(orchestrator.isUnlocked).toBe(false); + }); + + it('should return true after unlock (UnifiedCredentialStore)', async () => { + const orchestrator = new CredentialOrchestrator({ + useUnifiedStore: true, + namespace: 'test-unlocked' + }); + + await orchestrator.unlock({ + method: 'password', + password: 'test-password' + }); + + expect(orchestrator.isUnlocked).toBe(true); + }); + + it('should return false after password is set (legacy store clears in-memory)', () => { + const orchestrator = new CredentialOrchestrator({ + namespace: 'test-unlocked-legacy' + }); + + // Legacy store clears in-memory password after persisting + orchestrator.getCredentialStore().password = 'test-password'; + + // isUnlocked checks password, which is cleared after persist + // This is expected legacy behavior - password is only kept in memory + // when decrypting existing data, not when first setting up encryption + expect(orchestrator.isUnlocked).toBe(false); + }); + }); + + describe('clear', () => { + it('should clear credentials (UnifiedCredentialStore)', async () => { + const orchestrator = new CredentialOrchestrator({ + useUnifiedStore: true, + namespace: 'test-clear' + }); + + const store = orchestrator.getCredentialStore(); + store.localKey = 'test-key'; + + orchestrator.clear(); + + expect(store.localKey).toBe(''); + }); + + it('should clear credentials (legacy store)', () => { + const orchestrator = new CredentialOrchestrator({ + namespace: 'test-clear-legacy' + }); + + const store = orchestrator.getCredentialStore(); + store.localKey = 'test-key'; + + orchestrator.clear(); + + expect(store.localKey).toBe(''); + }); + + it('should support memoryOnly flag', () => { + const orchestrator = new CredentialOrchestrator({ + useUnifiedStore: true, + namespace: 'test-clear-memory' + }); + + const store = orchestrator.getCredentialStore(); + const clearSpy = vi.spyOn(store, 'clear'); + + orchestrator.clear(true); + + expect(clearSpy).toHaveBeenCalledWith(true); + }); + }); + + describe('getCredentialStore', () => { + it('should return the underlying credential store', () => { + const orchestrator = new CredentialOrchestrator({ + useUnifiedStore: true + }); + + const store = orchestrator.getCredentialStore(); + + expect(store).toBeInstanceOf(UnifiedCredentialStore); + }); + + it('should return same instance on multiple calls', () => { + const orchestrator = new CredentialOrchestrator({}); + + const store1 = orchestrator.getCredentialStore(); + const store2 = orchestrator.getCredentialStore(); + + expect(store1).toBe(store2); + }); + }); + + describe('edge cases', () => { + it('should handle config with password for legacy store', () => { + new CredentialOrchestrator({ + namespace: 'test-with-password', + password: 'initial-password' + }); + + // Legacy store clears password after persisting encrypted data, + // so password getter returns empty string + // But the store should have created cipher and salt + const key = 'lnc-web:test-with-password'; + const persisted = JSON.parse(localStorage.getItem(key) || '{}'); + expect(persisted.cipher).toBeDefined(); + expect(persisted.salt).toBeDefined(); + }); + + it('should prioritize custom credentialStore over useUnifiedStore', () => { + const customStore: CredentialStore = { + password: undefined, + pairingPhrase: '', + serverHost: '', + localKey: '', + remoteKey: '', + isPaired: false, + clear: vi.fn() + }; + + const orchestrator = new CredentialOrchestrator({ + credentialStore: customStore, + useUnifiedStore: true // This should be ignored + }); + + expect(orchestrator.getCredentialStore()).toBe(customStore); + }); + }); +}); diff --git a/lib/credentialOrchestrator.ts b/lib/credentialOrchestrator.ts new file mode 100644 index 0000000..203518e --- /dev/null +++ b/lib/credentialOrchestrator.ts @@ -0,0 +1,206 @@ +import UnifiedCredentialStore, { + AuthenticationInfo +} from './stores/unifiedCredentialStore'; +import { CredentialStore, LncConfig, UnlockOptions } from './types/lnc'; +import LncCredentialStore from './util/credentialStore'; +import { log } from './util/log'; + +/** + * Orchestrates credential management and authentication operations. + * Handles credential store creation, authentication, and persistence. + * + * This is a minimal implementation for password authentication. + * Session and passkey support will be added in PR 9. + */ +export class CredentialOrchestrator { + private currentCredentialStore: CredentialStore; + + constructor(config: LncConfig) { + this.currentCredentialStore = this.createCredentialStore(config); + } + + /** + * Get the credential store (for public access via LNC getter) + */ + getCredentialStore(): CredentialStore { + return this.currentCredentialStore; + } + + /** + * Create the appropriate credential store based on configuration + */ + private createCredentialStore(config: LncConfig): CredentialStore { + // If credential store is explicitly provided, use it + if (config.credentialStore) { + log.info( + '[CredentialOrchestrator] Using custom credential store from config' + ); + return config.credentialStore; + } + + // Use UnifiedCredentialStore when explicitly requested + // (Later PRs will add: || config.enableSessions || config.allowPasskeys) + if (config.useUnifiedStore) { + return this.createUnifiedStore(config); + } + + // Use legacy credential store for basic functionality (default) + return this.createLegacyStore(config); + } + + /** + * Create a UnifiedCredentialStore + */ + private createUnifiedStore(config: LncConfig): UnifiedCredentialStore { + log.info('[CredentialOrchestrator] Creating UnifiedCredentialStore'); + + const store = new UnifiedCredentialStore(config); + + // Set initial values from config + if (!store.isPaired && config.serverHost) { + store.serverHost = config.serverHost; + } + if (config.pairingPhrase) { + store.pairingPhrase = config.pairingPhrase; + } + + return store; + } + + /** + * Create a legacy LncCredentialStore + */ + private createLegacyStore(config: LncConfig): LncCredentialStore { + log.info('[CredentialOrchestrator] Creating legacy LncCredentialStore'); + + const store = new LncCredentialStore( + config.namespace || 'default', + config.password + ); + + // Don't overwrite an existing serverHost if we're already paired + if (!store.isPaired && config.serverHost) { + store.serverHost = config.serverHost; + } + if (config.pairingPhrase) { + store.pairingPhrase = config.pairingPhrase; + } + + return store; + } + + /** + * Unlock the credential store using the specified method + */ + async unlock(options: UnlockOptions): Promise { + const unifiedStore = this.getUnifiedStore(); + if (unifiedStore) { + return await unifiedStore.unlock(options); + } + + // Legacy fallback: just set password (it auto-persists) + if (options.method === 'password' && options.password) { + try { + this.currentCredentialStore.password = options.password; + return true; + } catch (error) { + log.error('[CredentialOrchestrator] Legacy unlock failed:', error); + return false; + } + } + log.warn( + '[CredentialOrchestrator] Legacy unlock failed: missing or empty password for method "password".' + ); + return false; + } + + /** + * Persist credentials with password encryption + * This is the main method to save credentials after a successful connection. + */ + async persistWithPassword(password: string): Promise { + const unifiedStore = this.getUnifiedStore(); + + if (unifiedStore) { + // UnifiedCredentialStore: unlock then persist + const unlocked = await unifiedStore.unlock({ + method: 'password', + password + }); + + if (!unlocked) { + const authInfo = await unifiedStore.getAuthenticationInfo(); + throw new Error( + `Failed to unlock credentials with password. ` + + `Authentication state: isUnlocked=${authInfo.isUnlocked}, ` + + `hasStoredCredentials=${authInfo.hasStoredCredentials}, ` + + `preferredUnlockMethod=${authInfo.preferredUnlockMethod}` + ); + } + + await unifiedStore.persistCredentials(); + log.info( + '[CredentialOrchestrator] Credentials persisted with UnifiedCredentialStore' + ); + } else { + // Legacy: just set password (it auto-persists) + this.currentCredentialStore.password = password; + log.info( + '[CredentialOrchestrator] Credentials persisted with legacy store' + ); + } + } + + /** + * Get authentication information + */ + async getAuthenticationInfo(): Promise { + const unifiedStore = this.getUnifiedStore(); + if (unifiedStore) { + return await unifiedStore.getAuthenticationInfo(); + } + + // Fallback for legacy credential store + return { + isUnlocked: !!this.currentCredentialStore.password, + hasStoredCredentials: this.currentCredentialStore.isPaired, + preferredUnlockMethod: 'password' as const + }; + } + + /** + * Check if credentials are unlocked + */ + get isUnlocked(): boolean { + const unifiedStore = this.getUnifiedStore(); + if (unifiedStore) { + return unifiedStore.isUnlocked(); + } + // Legacy: check if password is set + return !!this.currentCredentialStore.password; + } + + /** + * Check if credentials are paired + */ + get isPaired(): boolean { + return this.currentCredentialStore.isPaired; + } + + /** + * Clear stored credentials + */ + clear(memoryOnly?: boolean): void { + this.currentCredentialStore.clear(memoryOnly); + log.info('[CredentialOrchestrator] Credentials cleared', { memoryOnly }); + } + + /** + * Get the unified store if available + */ + private getUnifiedStore(): UnifiedCredentialStore | undefined { + return this.currentCredentialStore instanceof UnifiedCredentialStore + ? this.currentCredentialStore + : undefined; + } +} diff --git a/lib/index.ts b/lib/index.ts index d1f96a9..15e08ec 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -2,6 +2,7 @@ require('./wasm_exec'); import LNC from './lnc'; +import { CredentialOrchestrator } from './credentialOrchestrator'; import { WasmManager } from './wasmManager'; // polyfill @@ -12,7 +13,13 @@ if (!WebAssembly.instantiateStreaming) { }; } -export type { LncConfig, CredentialStore } from './types/lnc'; +export type { + LncConfig, + CredentialStore, + UnlockMethod, + UnlockOptions +} from './types/lnc'; +export type { AuthenticationInfo } from './stores/unifiedCredentialStore'; export * from '@lightninglabs/lnc-core'; export default LNC; -export { WasmManager }; +export { CredentialOrchestrator, WasmManager }; diff --git a/lib/lnc.test.ts b/lib/lnc.test.ts index d3c36be..c983fb6 100644 --- a/lib/lnc.test.ts +++ b/lib/lnc.test.ts @@ -10,6 +10,8 @@ import { import { createMockSetup, MockSetup } from '../test/utils/mock-factory'; import { globalAccess, testData } from '../test/utils/test-helpers'; import LNC from './lnc'; +import UnifiedCredentialStore from './stores/unifiedCredentialStore'; +import LncCredentialStore from './util/credentialStore'; import { WasmGlobal } from './types/lnc'; describe('LNC Core Class', () => { @@ -861,4 +863,124 @@ describe('LNC Core Class', () => { expect(lnc.credentials.remoteKey).toBe('test_remote_key'); }); }); + + describe('CredentialOrchestrator Integration', () => { + it('should use legacy LncCredentialStore by default', () => { + const lnc = new LNC({ namespace: 'test-legacy-default' }); + + expect(lnc.credentials).toBeInstanceOf(LncCredentialStore); + }); + + it('should use UnifiedCredentialStore when useUnifiedStore is true', () => { + const lnc = new LNC({ + useUnifiedStore: true, + namespace: 'test-unified' + }); + + expect(lnc.credentials).toBeInstanceOf(UnifiedCredentialStore); + }); + + it('should use custom credential store when provided', () => { + const customStore = { + password: undefined, + pairingPhrase: '', + serverHost: '', + localKey: '', + remoteKey: '', + isPaired: false, + clear: vi.fn() + }; + + const lnc = new LNC({ credentialStore: customStore }); + + expect(lnc.credentials).toBe(customStore); + }); + }); + + describe('Authentication Methods', () => { + it('should return isUnlocked from orchestrator', () => { + const lnc = new LNC({ + namespace: 'test-is-unlocked' + }); + + expect(lnc.isUnlocked).toBe(false); + }); + + it('should return isPaired from orchestrator', () => { + const lnc = new LNC({ + namespace: 'test-is-paired' + }); + + expect(lnc.isPaired).toBe(false); + }); + + it('should unlock credentials via orchestrator', async () => { + const lnc = new LNC({ + useUnifiedStore: true, + namespace: 'test-unlock' + }); + + const result = await lnc.unlock({ + method: 'password', + password: 'test-password' + }); + + expect(result).toBe(true); + expect(lnc.isUnlocked).toBe(true); + }); + + it('should persist credentials with password via orchestrator', async () => { + const lnc = new LNC({ + useUnifiedStore: true, + namespace: 'test-persist' + }); + + // Set some credentials first + lnc.credentials.localKey = 'test-local-key'; + lnc.credentials.remoteKey = 'test-remote-key'; + + await lnc.persistWithPassword('test-password'); + + expect(lnc.isUnlocked).toBe(true); + }); + + it('should get authentication info via orchestrator', async () => { + const lnc = new LNC({ + useUnifiedStore: true, + namespace: 'test-auth-info' + }); + + const info = await lnc.getAuthenticationInfo(); + + expect(info).toEqual({ + isUnlocked: false, + hasStoredCredentials: false, + preferredUnlockMethod: 'password' + }); + }); + + it('should clear credentials via orchestrator', () => { + const lnc = new LNC({ + useUnifiedStore: true, + namespace: 'test-clear' + }); + + lnc.credentials.localKey = 'test-key'; + lnc.clearCredentials(); + + expect(lnc.credentials.localKey).toBe(''); + }); + + it('should support memoryOnly flag in clearCredentials', () => { + const lnc = new LNC({ + namespace: 'test-clear-memory' + }); + + const clearSpy = vi.spyOn(lnc.credentials, 'clear'); + + lnc.clearCredentials(true); + + expect(clearSpy).toHaveBeenCalledWith(true); + }); + }); }); diff --git a/lib/lnc.ts b/lib/lnc.ts index 148f92a..21794f3 100644 --- a/lib/lnc.ts +++ b/lib/lnc.ts @@ -7,8 +7,9 @@ import { TaprootAssetsApi } from '@lightninglabs/lnc-core'; import { createRpc } from './api/createRpc'; -import { CredentialStore, LncConfig } from './types/lnc'; -import LncCredentialStore from './util/credentialStore'; +import { CredentialOrchestrator } from './credentialOrchestrator'; +import { AuthenticationInfo } from './stores/unifiedCredentialStore'; +import { CredentialStore, LncConfig, UnlockOptions } from './types/lnc'; import { WasmManager } from './wasmManager'; /** The default values for the LncConfig options */ @@ -19,7 +20,7 @@ export const DEFAULT_CONFIG = { } as Required; export default class LNC { - credentials: CredentialStore; + private orchestrator: CredentialOrchestrator; lnd: LndApi; loop: LoopApi; @@ -34,19 +35,8 @@ export default class LNC { // merge the passed in config with the defaults const config = Object.assign({}, DEFAULT_CONFIG, lncConfig); - if (config.credentialStore) { - this.credentials = config.credentialStore; - } else { - this.credentials = new LncCredentialStore( - config.namespace, - config.password - ); - // don't overwrite an existing serverHost if we're already paired - if (!this.credentials.isPaired) - this.credentials.serverHost = config.serverHost; - if (config.pairingPhrase) - this.credentials.pairingPhrase = config.pairingPhrase; - } + // Create orchestrator which handles store creation + this.orchestrator = new CredentialOrchestrator(config); // Initialize WASM manager with namespace and client code this.wasmManager = new WasmManager( @@ -63,6 +53,13 @@ export default class LNC { this.lit = new LitApi(createRpc, this); } + /** + * Gets the credential store for accessing credential properties + */ + get credentials(): CredentialStore { + return this.orchestrator.getCredentialStore(); + } + get isReady() { return this.wasmManager.isReady; } @@ -148,4 +145,55 @@ export default class LNC { ) { this.wasmManager.subscribe(method, request, onMessage, onError); } + + // + // Authentication methods (via CredentialOrchestrator) + // + + /** + * Check if credentials are currently unlocked + */ + get isUnlocked(): boolean { + return this.orchestrator.isUnlocked; + } + + /** + * Check if credentials are paired (have been persisted previously) + */ + get isPaired(): boolean { + return this.orchestrator.isPaired; + } + + /** + * Unlock the credential store using the specified method + * @param options The unlock options (method and credentials) + * @returns Promise resolving to true if unlock was successful + */ + async unlock(options: UnlockOptions): Promise { + return this.orchestrator.unlock(options); + } + + /** + * Persist credentials with password encryption. + * Call this after a successful connection to save credentials for future use. + * @param password The password to use for encryption + */ + async persistWithPassword(password: string): Promise { + return this.orchestrator.persistWithPassword(password); + } + + /** + * Get authentication information including unlock state and stored credentials + */ + async getAuthenticationInfo(): Promise { + return this.orchestrator.getAuthenticationInfo(); + } + + /** + * Clear stored credentials + * @param memoryOnly If true, only clears in-memory credentials + */ + clearCredentials(memoryOnly?: boolean): void { + this.orchestrator.clear(memoryOnly); + } } diff --git a/lib/stores/authStrategy.ts b/lib/stores/authStrategy.ts new file mode 100644 index 0000000..07a699c --- /dev/null +++ b/lib/stores/authStrategy.ts @@ -0,0 +1,36 @@ +import { UnlockMethod, UnlockOptions } from '../types/lnc'; + +/** + * Interface for authentication strategies used by UnifiedCredentialStore. + * Each strategy handles a specific authentication method (password, passkey, etc.) + * and provides a consistent interface for credential storage and retrieval. + */ +export interface AuthStrategy { + /** The authentication method this strategy handles */ + readonly method: UnlockMethod; + + /** Check if this strategy is supported in the current environment */ + get isSupported(): boolean; + + /** Check if this strategy is currently unlocked */ + get isUnlocked(): boolean; + + /** Check if this strategy has any stored credentials */ + get hasAnyCredentials(): boolean; + + /** Attempt to unlock this strategy with the provided options */ + unlock(options: UnlockOptions): Promise; + + /** Get a credential value from this strategy's storage */ + getCredential(key: string): Promise; + + /** Set a credential value in this strategy's storage */ + setCredential(key: string, value: string): Promise; + + /** Clear the strategy's state */ + clear(): void; + + /** Strategy-specific methods that may be implemented */ + hasStoredAuthData?(): boolean; // For passkeys + canAutoRestore?(): Promise; // For sessions +} diff --git a/lib/stores/credentialCache.test.ts b/lib/stores/credentialCache.test.ts new file mode 100644 index 0000000..c612e4c --- /dev/null +++ b/lib/stores/credentialCache.test.ts @@ -0,0 +1,462 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { log } from '../util/log'; +import { CredentialCache } from './credentialCache'; + +// Mock log methods to avoid noise in tests +vi.spyOn(log, 'info').mockImplementation(() => {}); + +describe('CredentialCache', () => { + let cache: CredentialCache; + + beforeEach(() => { + cache = new CredentialCache(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('Constructor', () => { + it('should create instance with empty cache', () => { + const newCache = new CredentialCache(); + + expect(newCache).toBeInstanceOf(CredentialCache); + expect(newCache.size()).toBe(0); + expect(newCache.isEmpty()).toBe(true); + }); + }); + + describe('get()', () => { + it('should return undefined for non-existent key', () => { + const result = cache.get('non-existent-key'); + + expect(result).toBeUndefined(); + }); + + it('should return the value for an existing key', () => { + const key = 'test-key'; + const value = 'test-value'; + + cache.set(key, value); + const result = cache.get(key); + + expect(result).toBe(value); + }); + + it('should return undefined after clearing the cache', () => { + const key = 'test-key'; + const value = 'test-value'; + + cache.set(key, value); + cache.clear(); + const result = cache.get(key); + + expect(result).toBeUndefined(); + }); + }); + + describe('set()', () => { + it('should set a value for a key', () => { + const key = 'test-key'; + const value = 'test-value'; + + cache.set(key, value); + + expect(cache.get(key)).toBe(value); + expect(cache.has(key)).toBe(true); + expect(cache.size()).toBe(1); + }); + + it('should overwrite existing value for the same key', () => { + const key = 'test-key'; + const originalValue = 'original-value'; + const newValue = 'new-value'; + + cache.set(key, originalValue); + cache.set(key, newValue); + + expect(cache.get(key)).toBe(newValue); + expect(cache.size()).toBe(1); + }); + + it('should handle multiple different keys', () => { + cache.set('key1', 'value1'); + cache.set('key2', 'value2'); + cache.set('key3', 'value3'); + + expect(cache.size()).toBe(3); + expect(cache.get('key1')).toBe('value1'); + expect(cache.get('key2')).toBe('value2'); + expect(cache.get('key3')).toBe('value3'); + }); + }); + + describe('has()', () => { + it('should return false for non-existent key', () => { + expect(cache.has('non-existent-key')).toBe(false); + }); + + it('should return true for existing key', () => { + const key = 'test-key'; + const value = 'test-value'; + + cache.set(key, value); + + expect(cache.has(key)).toBe(true); + }); + + it('should return false after clearing', () => { + const key = 'test-key'; + const value = 'test-value'; + + cache.set(key, value); + cache.clear(); + + expect(cache.has(key)).toBe(false); + }); + }); + + describe('hasAny()', () => { + it('should return false for empty cache', () => { + expect(cache.hasAny()).toBe(false); + }); + + it('should return true when cache has at least one credential', () => { + cache.set('test-key', 'test-value'); + + expect(cache.hasAny()).toBe(true); + }); + + it('should return false after clearing all credentials', () => { + cache.set('test-key', 'test-value'); + cache.clear(); + + expect(cache.hasAny()).toBe(false); + }); + }); + + describe('clear()', () => { + it('should clear all credentials from cache', () => { + cache.set('key1', 'value1'); + cache.set('key2', 'value2'); + cache.set('key3', 'value3'); + + expect(cache.size()).toBe(3); + + cache.clear(); + + expect(cache.size()).toBe(0); + expect(cache.hasAny()).toBe(false); + expect(cache.isEmpty()).toBe(true); + }); + + it('should handle clearing empty cache without error', () => { + expect(cache.size()).toBe(0); + + cache.clear(); + + expect(cache.size()).toBe(0); + }); + + it('should log when clearing', () => { + cache.set('key1', 'value1'); + cache.clear(); + + expect(log.info).toHaveBeenCalledWith('[CredentialCache] Cache cleared'); + }); + }); + + describe('getAll()', () => { + it('should return empty Map for empty cache', () => { + const result = cache.getAll(); + + expect(result).toBeInstanceOf(Map); + expect(result.size).toBe(0); + }); + + it('should return Map with all credentials', () => { + const credentials = new Map([ + ['key1', 'value1'], + ['key2', 'value2'], + ['key3', 'value3'] + ]); + + credentials.forEach((value, key) => { + cache.set(key, value); + }); + + const result = cache.getAll(); + + expect(result).toBeInstanceOf(Map); + expect(result.size).toBe(3); + credentials.forEach((expectedValue, key) => { + expect(result.get(key)).toBe(expectedValue); + }); + }); + + it('should return a copy, not reference to internal cache', () => { + cache.set('test-key', 'test-value'); + + const result = cache.getAll(); + result.set('new-key', 'new-value'); + + expect(cache.has('new-key')).toBe(false); + }); + }); + + describe('hydrate()', () => { + it('should populate cache from credentials record', () => { + const credentials = { + localKey: 'test-local-key', + remoteKey: 'test-remote-key', + pairingPhrase: 'test-pairing-phrase', + serverHost: 'test-server-host.example.com:443' + }; + + cache.hydrate(credentials); + + expect(cache.size()).toBe(4); + expect(cache.get('localKey')).toBe(credentials.localKey); + expect(cache.get('remoteKey')).toBe(credentials.remoteKey); + expect(cache.get('pairingPhrase')).toBe(credentials.pairingPhrase); + expect(cache.get('serverHost')).toBe(credentials.serverHost); + }); + + it('should log hydration with keys', () => { + const credentials = { + key1: 'value1', + key2: 'value2' + }; + + cache.hydrate(credentials); + + expect(log.info).toHaveBeenCalledWith( + '[CredentialCache] Hydrated with credentials:', + { keys: ['key1', 'key2'] } + ); + }); + + it('should overwrite existing values', () => { + cache.set('localKey', 'old-value'); + + cache.hydrate({ + localKey: 'new-value', + remoteKey: 'remote-value' + }); + + expect(cache.get('localKey')).toBe('new-value'); + expect(cache.get('remoteKey')).toBe('remote-value'); + expect(cache.size()).toBe(2); + }); + + it('should handle empty credentials record', () => { + cache.hydrate({}); + + expect(cache.isEmpty()).toBe(true); + }); + }); + + describe('keys()', () => { + it('should return empty array for empty cache', () => { + const result = cache.keys(); + + expect(result).toEqual([]); + }); + + it('should return array of all keys', () => { + cache.set('key1', 'value1'); + cache.set('key2', 'value2'); + cache.set('key3', 'value3'); + + const result = cache.keys(); + + expect(result).toHaveLength(3); + expect(result).toContain('key1'); + expect(result).toContain('key2'); + expect(result).toContain('key3'); + }); + }); + + describe('values()', () => { + it('should return empty array for empty cache', () => { + const result = cache.values(); + + expect(result).toEqual([]); + }); + + it('should return array of all values', () => { + const testData = [ + ['key1', 'value1'], + ['key2', 'value2'], + ['key3', 'value3'] + ]; + + testData.forEach(([key, value]) => cache.set(key, value)); + + const result = cache.values(); + + expect(result).toHaveLength(3); + expect(result).toContain('value1'); + expect(result).toContain('value2'); + expect(result).toContain('value3'); + }); + }); + + describe('size()', () => { + it('should return 0 for empty cache', () => { + expect(cache.size()).toBe(0); + }); + + it('should return correct size after adding credentials', () => { + expect(cache.size()).toBe(0); + + cache.set('key1', 'value1'); + expect(cache.size()).toBe(1); + + cache.set('key2', 'value2'); + expect(cache.size()).toBe(2); + + cache.set('key3', 'value3'); + expect(cache.size()).toBe(3); + }); + + it('should return 0 after clearing', () => { + cache.set('key1', 'value1'); + cache.set('key2', 'value2'); + + expect(cache.size()).toBe(2); + + cache.clear(); + expect(cache.size()).toBe(0); + }); + }); + + describe('isEmpty()', () => { + it('should return true for empty cache', () => { + expect(cache.isEmpty()).toBe(true); + }); + + it('should return false when cache has credentials', () => { + cache.set('test-key', 'test-value'); + + expect(cache.isEmpty()).toBe(false); + }); + + it('should return true after clearing', () => { + cache.set('test-key', 'test-value'); + cache.clear(); + + expect(cache.isEmpty()).toBe(true); + }); + }); + + describe('entries()', () => { + it('should return empty array for empty cache', () => { + const result = cache.entries(); + + expect(result).toEqual([]); + }); + + it('should return array of key-value pairs', () => { + const testData = [ + ['key1', 'value1'], + ['key2', 'value2'], + ['key3', 'value3'] + ]; + + testData.forEach(([key, value]) => cache.set(key, value)); + + const result = cache.entries(); + + expect(result).toHaveLength(3); + testData.forEach(([expectedKey, expectedValue]) => { + const entry = result.find(([key]) => key === expectedKey); + expect(entry).toEqual([expectedKey, expectedValue]); + }); + }); + }); + + describe('snapshot()', () => { + it('should return empty object for empty cache', () => { + const result = cache.snapshot(); + + expect(result).toEqual({}); + }); + + it('should return object with all credentials', () => { + const testData = { + key1: 'value1', + key2: 'value2', + key3: 'value3' + }; + + Object.entries(testData).forEach(([key, value]) => { + cache.set(key, value); + }); + + const result = cache.snapshot(); + + expect(result).toEqual(testData); + }); + + it('should return a copy, not reference to internal cache', () => { + cache.set('test-key', 'test-value'); + + const result = cache.snapshot(); + result['new-key'] = 'new-value'; + + expect(cache.has('new-key')).toBe(false); + }); + }); + + describe('Integration tests', () => { + it('should support full credential lifecycle', () => { + // Start empty + expect(cache.isEmpty()).toBe(true); + + // Add credentials + cache.set('localKey', 'test-local'); + cache.set('remoteKey', 'test-remote'); + cache.set('pairingPhrase', 'test-phrase'); + + expect(cache.size()).toBe(3); + expect(cache.hasAny()).toBe(true); + + // Access credentials + expect(cache.get('localKey')).toBe('test-local'); + expect(cache.get('remoteKey')).toBe('test-remote'); + expect(cache.get('pairingPhrase')).toBe('test-phrase'); + + // Check iteration methods + expect(cache.keys()).toEqual(['localKey', 'remoteKey', 'pairingPhrase']); + expect(cache.values()).toEqual([ + 'test-local', + 'test-remote', + 'test-phrase' + ]); + + // Hydrate (should overwrite existing) + const newCredentials = { + localKey: 'new-local-key', + remoteKey: 'new-remote-key', + pairingPhrase: 'new-pairing-phrase', + serverHost: 'new-server-host.example.com:443' + }; + cache.hydrate(newCredentials); + + expect(cache.size()).toBe(4); + expect(cache.get('localKey')).toBe(newCredentials.localKey); + expect(cache.get('serverHost')).toBe(newCredentials.serverHost); + + // Snapshot + const snapshot = cache.snapshot(); + expect(Object.keys(snapshot)).toHaveLength(4); + + // Clear + cache.clear(); + expect(cache.isEmpty()).toBe(true); + expect(cache.size()).toBe(0); + }); + }); +}); diff --git a/lib/stores/credentialCache.ts b/lib/stores/credentialCache.ts new file mode 100644 index 0000000..efcf913 --- /dev/null +++ b/lib/stores/credentialCache.ts @@ -0,0 +1,107 @@ +import { log } from '../util/log'; + +/** + * Handles in-memory credential storage and access. + * Provides fast access to credentials during the session lifecycle. + */ +export class CredentialCache { + private cache = new Map(); + + /** + * Get a credential value by key + */ + get(key: string): string | undefined { + return this.cache.get(key); + } + + /** + * Set a credential value by key + */ + set(key: string, value: string): void { + this.cache.set(key, value); + } + + /** + * Check if a credential exists + */ + has(key: string): boolean { + return this.cache.has(key); + } + + /** + * Check if any credentials are stored + */ + hasAny(): boolean { + return this.cache.size > 0; + } + + /** + * Clear all cached credentials + */ + clear(): void { + this.cache.clear(); + log.info('[CredentialCache] Cache cleared'); + } + + /** + * Get all cached credentials as a Map + */ + getAll(): Map { + return new Map(this.cache); + } + + /** + * Populate cache from a record of credentials + */ + hydrate(credentials: Record): void { + for (const [key, value] of Object.entries(credentials)) { + this.cache.set(key, value); + } + + log.info('[CredentialCache] Hydrated with credentials:', { + keys: Object.keys(credentials) + }); + } + + /** + * Get credential keys for iteration + */ + keys(): string[] { + return Array.from(this.cache.keys()); + } + + /** + * Get credential values for iteration + */ + values(): string[] { + return Array.from(this.cache.values()); + } + + /** + * Get cache size + */ + size(): number { + return this.cache.size; + } + + /** + * Check if cache is empty + */ + isEmpty(): boolean { + return this.cache.size === 0; + } + + /** + * Get cache entries for iteration + */ + entries(): [string, string][] { + return Array.from(this.cache.entries()); + } + + /** + * Create a snapshot of current cache state (for debugging) + */ + snapshot(): Record { + return Object.fromEntries(this.cache.entries()); + } +} diff --git a/lib/stores/passwordStrategy.test.ts b/lib/stores/passwordStrategy.test.ts new file mode 100644 index 0000000..52990af --- /dev/null +++ b/lib/stores/passwordStrategy.test.ts @@ -0,0 +1,313 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { PasswordCredentialRepository } from '../repositories/passwordCredentialRepository'; +import { log } from '../util/log'; +import { PasswordStrategy } from './passwordStrategy'; + +// Mock PasswordCredentialRepository +const mockRepository = { + unlock: vi.fn(), + getCredential: vi.fn(), + setCredential: vi.fn(), + isUnlocked: false, + hasAnyCredentials: false, + clear: vi.fn() +}; + +// Mock the constructor to return our mock +vi.mock('../repositories/passwordCredentialRepository', () => ({ + PasswordCredentialRepository: vi.fn().mockImplementation(() => mockRepository) +})); + +describe('PasswordStrategy', () => { + let strategy: PasswordStrategy; + + beforeEach(() => { + vi.clearAllMocks(); + // Reset mock defaults + mockRepository.unlock.mockResolvedValue(undefined); + mockRepository.getCredential.mockResolvedValue('test-value'); + mockRepository.setCredential.mockResolvedValue(undefined); + mockRepository.isUnlocked = false; + mockRepository.hasAnyCredentials = false; + + strategy = new PasswordStrategy('test-namespace'); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('Constructor', () => { + it('should create instance with namespace', () => { + expect(strategy).toBeInstanceOf(PasswordStrategy); + expect(strategy.method).toBe('password'); + }); + + it('should initialize repository with namespace', () => { + expect(PasswordCredentialRepository).toHaveBeenCalledWith( + 'test-namespace', + expect.anything() + ); + }); + }); + + describe('isSupported', () => { + it('should always return true', () => { + expect(strategy.isSupported).toBe(true); + }); + }); + + describe('isUnlocked', () => { + it('should return repository unlock status', () => { + mockRepository.isUnlocked = true; + expect(strategy.isUnlocked).toBe(true); + + mockRepository.isUnlocked = false; + expect(strategy.isUnlocked).toBe(false); + }); + }); + + describe('unlock()', () => { + it('should unlock with password method and return true', async () => { + mockRepository.isUnlocked = true; // Simulate successful unlock + + const result = await strategy.unlock({ + method: 'password', + password: 'test-password' + }); + + expect(result).toBe(true); + expect(mockRepository.unlock).toHaveBeenCalledWith({ + method: 'password', + password: 'test-password' + }); + }); + + it('should return false for non-password method', async () => { + // Cast to any since 'passkey' is not a valid UnlockMethod yet (added in PR 7) + const result = await strategy.unlock({ method: 'passkey' } as any); + + expect(result).toBe(false); + expect(mockRepository.unlock).not.toHaveBeenCalled(); + }); + + it('should return false when password is not provided', async () => { + const result = await strategy.unlock({ method: 'password' } as any); + + expect(result).toBe(false); + expect(mockRepository.unlock).not.toHaveBeenCalled(); + }); + + it('should return false when password is empty', async () => { + const result = await strategy.unlock({ + method: 'password', + password: '' + }); + + expect(result).toBe(false); + expect(mockRepository.unlock).not.toHaveBeenCalled(); + }); + + it('should return false when repository unlock fails', async () => { + const error = new Error('Unlock failed'); + mockRepository.unlock.mockRejectedValue(error); + + const result = await strategy.unlock({ + method: 'password', + password: 'test-password' + }); + + expect(result).toBe(false); + expect(mockRepository.unlock).toHaveBeenCalled(); + }); + + it('should log error when unlock fails', async () => { + const error = new Error('Unlock failed'); + mockRepository.unlock.mockRejectedValue(error); + const spy = vi.spyOn(log, 'error'); + + await strategy.unlock({ + method: 'password', + password: 'test-password' + }); + + expect(spy).toHaveBeenCalledWith( + '[PasswordStrategy] Unlock failed:', + error + ); + }); + }); + + describe('hasAnyCredentials', () => { + it('should return repository credential status', () => { + mockRepository.hasAnyCredentials = true; + expect(strategy.hasAnyCredentials).toBe(true); + + mockRepository.hasAnyCredentials = false; + expect(strategy.hasAnyCredentials).toBe(false); + }); + }); + + describe('getCredential()', () => { + it('should return credential value when unlocked', async () => { + mockRepository.isUnlocked = true; + + const result = await strategy.getCredential('test-key'); + + expect(result).toBe('test-value'); + expect(mockRepository.getCredential).toHaveBeenCalledWith('test-key'); + }); + + it('should return undefined when not unlocked', async () => { + mockRepository.isUnlocked = false; + const spy = vi.spyOn(log, 'warn'); + + const result = await strategy.getCredential('test-key'); + + expect(result).toBeUndefined(); + expect(mockRepository.getCredential).not.toHaveBeenCalled(); + expect(spy).toHaveBeenCalledWith( + '[PasswordStrategy] Cannot get credential - not unlocked' + ); + }); + + it('should return undefined when repository throws error', async () => { + const error = new Error('Get credential failed'); + mockRepository.isUnlocked = true; + mockRepository.getCredential.mockRejectedValue(error); + const spy = vi.spyOn(log, 'error'); + + const result = await strategy.getCredential('test-key'); + + expect(result).toBeUndefined(); + expect(spy).toHaveBeenCalledWith( + '[PasswordStrategy] Failed to get credential test-key:', + error + ); + }); + }); + + describe('setCredential()', () => { + it('should set credential when unlocked', async () => { + mockRepository.isUnlocked = true; + + await strategy.setCredential('test-key', 'test-value'); + + expect(mockRepository.setCredential).toHaveBeenCalledWith( + 'test-key', + 'test-value' + ); + }); + + it('should not set credential when not unlocked', async () => { + mockRepository.isUnlocked = false; + const spy = vi.spyOn(log, 'warn'); + + await strategy.setCredential('test-key', 'test-value'); + + expect(mockRepository.setCredential).not.toHaveBeenCalled(); + expect(spy).toHaveBeenCalledWith( + '[PasswordStrategy] Cannot set credential - not unlocked' + ); + }); + + it('should propagate repository errors', async () => { + const error = new Error('Set credential failed'); + mockRepository.isUnlocked = true; + mockRepository.setCredential.mockRejectedValue(error); + + await expect( + strategy.setCredential('test-key', 'test-value') + ).rejects.toThrow('Set credential failed'); + }); + + it('should log error when set fails', async () => { + const error = new Error('Set credential failed'); + mockRepository.isUnlocked = true; + mockRepository.setCredential.mockRejectedValue(error); + const spy = vi.spyOn(log, 'error'); + + try { + await strategy.setCredential('test-key', 'test-value'); + } catch { + // Expected to throw + } + + expect(spy).toHaveBeenCalledWith( + '[PasswordStrategy] Failed to set credential test-key:', + error + ); + }); + }); + + describe('clear()', () => { + it('should clear repository', () => { + strategy.clear(); + + expect(mockRepository.clear).toHaveBeenCalled(); + }); + }); + + describe('Integration tests', () => { + it('should support full authentication workflow', async () => { + // Initially not unlocked + expect(strategy.isUnlocked).toBe(false); + + // Unlock + mockRepository.isUnlocked = true; + const unlockResult = await strategy.unlock({ + method: 'password', + password: 'test-password' + }); + expect(unlockResult).toBe(true); + expect(strategy.isUnlocked).toBe(true); + + // Set credentials + await strategy.setCredential('localKey', 'test-local'); + await strategy.setCredential('remoteKey', 'test-remote'); + + // Get credentials + const localKey = await strategy.getCredential('localKey'); + const remoteKey = await strategy.getCredential('remoteKey'); + + expect(localKey).toBe('test-value'); + expect(remoteKey).toBe('test-value'); + + // Check credentials exist + mockRepository.hasAnyCredentials = true; + expect(strategy.hasAnyCredentials).toBe(true); + + // Clear + strategy.clear(); + expect(mockRepository.clear).toHaveBeenCalled(); + }); + + it('should handle unlock failure gracefully', async () => { + const error = new Error('Unlock failed'); + mockRepository.unlock.mockRejectedValue(error); + + const unlockResult = await strategy.unlock({ + method: 'password', + password: 'wrong-password' + }); + + expect(unlockResult).toBe(false); + expect(strategy.isUnlocked).toBe(false); + }); + + it('should work with different namespaces', () => { + const strategy1 = new PasswordStrategy('namespace1'); + const strategy2 = new PasswordStrategy('namespace2'); + + expect(strategy1).not.toBe(strategy2); + expect(PasswordCredentialRepository).toHaveBeenCalledWith( + 'namespace1', + expect.anything() + ); + expect(PasswordCredentialRepository).toHaveBeenCalledWith( + 'namespace2', + expect.anything() + ); + }); + }); +}); diff --git a/lib/stores/passwordStrategy.ts b/lib/stores/passwordStrategy.ts new file mode 100644 index 0000000..c053e41 --- /dev/null +++ b/lib/stores/passwordStrategy.ts @@ -0,0 +1,107 @@ +import { PasswordEncryptionService } from '../encryption/passwordEncryptionService'; +import { PasswordCredentialRepository } from '../repositories/passwordCredentialRepository'; +import { UnlockOptions } from '../types/lnc'; +import { log } from '../util/log'; +import { AuthStrategy } from './authStrategy'; + +/** + * Password-based authentication strategy. + * Handles password encryption/decryption and localStorage persistence. + */ +export class PasswordStrategy implements AuthStrategy { + readonly method = 'password' as const; + private repository: PasswordCredentialRepository; + + constructor(namespace: string) { + const encryption = new PasswordEncryptionService(); + this.repository = new PasswordCredentialRepository(namespace, encryption); + } + + /** + * Return true if this strategy can be used in the current environment. + * Password auth is always supported. + */ + get isSupported(): boolean { + return true; // Password auth is always supported + } + + /** + * Returns true when the underlying repository is unlocked. + */ + get isUnlocked(): boolean { + return this.repository.isUnlocked; + } + + /** + * Return true if this strategy has any stored credentials in its backing store. + */ + get hasAnyCredentials(): boolean { + return this.repository.hasAnyCredentials; + } + + /** + * Attempt to unlock this strategy using the provided password. + * Returns false if the method is not `password` or unlock fails. + */ + async unlock(options: UnlockOptions): Promise { + if (options.method !== 'password') { + return false; + } + + if (!options.password) { + log.error('[PasswordStrategy] Password required for unlock'); + return false; + } + + try { + await this.repository.unlock(options); + return true; + } catch (error) { + log.error('[PasswordStrategy] Unlock failed:', error); + return false; + } + } + + /** + * Get a decrypted credential value by key from the backing store, + * or undefined if the strategy is not unlocked or the value is missing. + */ + async getCredential(key: string): Promise { + if (!this.isUnlocked) { + log.warn('[PasswordStrategy] Cannot get credential - not unlocked'); + return undefined; + } + + try { + return await this.repository.getCredential(key); + } catch (error) { + log.error(`[PasswordStrategy] Failed to get credential ${key}:`, error); + return undefined; + } + } + + /** + * Encrypt and persist a credential value in the backing store. + * Logs and returns without throwing if the strategy is not unlocked. + */ + async setCredential(key: string, value: string): Promise { + if (!this.isUnlocked) { + log.warn('[PasswordStrategy] Cannot set credential - not unlocked'); + return; + } + + try { + await this.repository.setCredential(key, value); + } catch (error) { + log.error(`[PasswordStrategy] Failed to set credential ${key}:`, error); + throw error; + } + } + + /** + * Clear all data managed by this strategy from its backing store. + */ + clear(): void { + this.repository.clear(); + } +} diff --git a/lib/stores/strategyManager.test.ts b/lib/stores/strategyManager.test.ts new file mode 100644 index 0000000..8881ed4 --- /dev/null +++ b/lib/stores/strategyManager.test.ts @@ -0,0 +1,191 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { log } from '../util/log'; +import { PasswordStrategy } from './passwordStrategy'; +import { StrategyManager } from './strategyManager'; + +// Mock password strategy +const mockPasswordStrategy = { + method: 'password', + isSupported: true, + isUnlocked: false, + hasAnyCredentials: false, + hasStoredAuthData: vi.fn(), + clear: vi.fn() +}; + +// Mock strategy constructors +vi.mock('./passwordStrategy', () => ({ + PasswordStrategy: vi.fn().mockImplementation(() => mockPasswordStrategy) +})); + +// Mock log methods +vi.spyOn(log, 'info').mockImplementation(() => {}); + +describe('StrategyManager', () => { + let strategyManager: StrategyManager; + const baseConfig = { + namespace: 'test-namespace' + }; + + beforeEach(() => { + vi.clearAllMocks(); + // Reset mock defaults + mockPasswordStrategy.isSupported = true; + mockPasswordStrategy.isUnlocked = false; + mockPasswordStrategy.hasAnyCredentials = false; + mockPasswordStrategy.hasStoredAuthData.mockReturnValue(false); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('Constructor', () => { + it('should register password strategy by default', () => { + strategyManager = new StrategyManager(baseConfig); + + expect(PasswordStrategy).toHaveBeenCalledWith('test-namespace'); + expect(strategyManager.getStrategy('password')).toBe( + mockPasswordStrategy + ); + }); + + it('should use default namespace when not provided', () => { + strategyManager = new StrategyManager({}); + + expect(PasswordStrategy).toHaveBeenCalledWith('default'); + }); + + it('should log registered strategies', () => { + const spy = vi.spyOn(log, 'info'); + + strategyManager = new StrategyManager(baseConfig); + + expect(spy).toHaveBeenCalledWith( + '[StrategyManager] Registered strategies: password' + ); + }); + }); + + describe('getStrategy()', () => { + beforeEach(() => { + strategyManager = new StrategyManager(baseConfig); + }); + + it('should return strategy by method', () => { + expect(strategyManager.getStrategy('password')).toBe( + mockPasswordStrategy + ); + }); + + it('should return undefined for unregistered method', () => { + // Cast to any since 'passkey' is not a valid UnlockMethod yet + expect(strategyManager.getStrategy('passkey' as any)).toBeUndefined(); + }); + }); + + describe('getSupportedMethods()', () => { + beforeEach(() => { + strategyManager = new StrategyManager(baseConfig); + }); + + it('should return all supported methods', () => { + mockPasswordStrategy.isSupported = true; + + const methods = strategyManager.getSupportedMethods(); + + expect(methods).toEqual(['password']); + }); + + it('should filter out unsupported methods', () => { + mockPasswordStrategy.isSupported = false; + + const methods = strategyManager.getSupportedMethods(); + + expect(methods).toEqual([]); + }); + }); + + describe('preferredMethod', () => { + beforeEach(() => { + strategyManager = new StrategyManager(baseConfig); + }); + + it('should return password as the preferred method', () => { + const method = strategyManager.preferredMethod; + + expect(method).toBe('password'); + }); + }); + + describe('hasAnyCredentials', () => { + beforeEach(() => { + strategyManager = new StrategyManager(baseConfig); + }); + + it('should return true when password strategy has credentials', () => { + mockPasswordStrategy.hasAnyCredentials = true; + + const result = strategyManager.hasAnyCredentials; + + expect(result).toBe(true); + }); + + it('should return false when no strategy has credentials', () => { + mockPasswordStrategy.hasAnyCredentials = false; + + const result = strategyManager.hasAnyCredentials; + + expect(result).toBe(false); + }); + }); + + describe('clearAll()', () => { + beforeEach(() => { + strategyManager = new StrategyManager(baseConfig); + }); + + it('should clear all registered strategies', () => { + strategyManager.clearAll(); + + expect(mockPasswordStrategy.clear).toHaveBeenCalled(); + }); + + it('should log clear operation', () => { + const spy = vi.spyOn(log, 'info'); + + strategyManager.clearAll(); + + expect(spy).toHaveBeenCalledWith( + '[StrategyManager] Cleared all strategies' + ); + }); + }); + + describe('Integration tests', () => { + it('should handle full strategy lifecycle', () => { + strategyManager = new StrategyManager(baseConfig); + + // Check initial state + expect(strategyManager.hasAnyCredentials).toBe(false); + expect(strategyManager.preferredMethod).toBe('password'); + + // Simulate having credentials + mockPasswordStrategy.hasAnyCredentials = true; + expect(strategyManager.hasAnyCredentials).toBe(true); + + // Clear all + strategyManager.clearAll(); + expect(mockPasswordStrategy.clear).toHaveBeenCalled(); + }); + + it('should work with different namespaces', () => { + const strategyManager1 = new StrategyManager({ namespace: 'namespace1' }); + const strategyManager2 = new StrategyManager({ namespace: 'namespace2' }); + + expect(strategyManager1).not.toBe(strategyManager2); + expect(PasswordStrategy).toHaveBeenCalledWith('namespace1'); + expect(PasswordStrategy).toHaveBeenCalledWith('namespace2'); + }); + }); +}); diff --git a/lib/stores/strategyManager.ts b/lib/stores/strategyManager.ts new file mode 100644 index 0000000..5cc1941 --- /dev/null +++ b/lib/stores/strategyManager.ts @@ -0,0 +1,88 @@ +import { LncConfig, UnlockMethod } from '../types/lnc'; +import { log } from '../util/log'; +import { AuthStrategy } from './authStrategy'; +import { PasswordStrategy } from './passwordStrategy'; + +/** + * Manages authentication strategies and their lifecycle. + * Handles strategy registration, lookup, and coordination. + */ +export class StrategyManager { + private strategies = new Map(); + + constructor(config: LncConfig) { + this.registerStrategies(config); + } + + /** + * Check if any strategy has stored credentials + */ + get hasAnyCredentials(): boolean { + return Array.from(this.strategies.values()).some( + (strategy) => strategy.hasAnyCredentials + ); + } + + /** + * Determine the preferred unlock method based on availability and priority. + * Note: Session and passkey preference logic will be added in later PRs. + */ + get preferredMethod(): UnlockMethod { + // For now, only password is available + return 'password'; + } + + /** + * Get a strategy by unlock method + */ + getStrategy(method: UnlockMethod): AuthStrategy | undefined { + return this.strategies.get(method); + } + + /** + * Get all supported unlock methods + */ + getSupportedMethods(): UnlockMethod[] { + const methods: UnlockMethod[] = []; + + Array.from(this.strategies.entries()).forEach(([method, strategy]) => { + if (strategy.isSupported) { + methods.push(method); + } + }); + + return methods; + } + + /** + * Clear all strategies + */ + clearAll(): void { + Array.from(this.strategies.values()).forEach((strategy) => { + strategy.clear(); + }); + log.info('[StrategyManager] Cleared all strategies'); + } + + // + // Private methods + // + + /** + * Register authentication strategies based on configuration. + * Note: Only password strategy is available in this PR. + * Passkey and session strategies are added in later PRs. + */ + private registerStrategies(config: LncConfig): void { + const namespace = config.namespace || 'default'; + + // Always register password strategy (available in all configurations) + this.strategies.set('password', new PasswordStrategy(namespace)); + + log.info( + `[StrategyManager] Registered strategies: ${Array.from( + this.strategies.keys() + ).join(', ')}` + ); + } +} diff --git a/lib/stores/unifiedCredentialStore.test.ts b/lib/stores/unifiedCredentialStore.test.ts new file mode 100644 index 0000000..86f3c7e --- /dev/null +++ b/lib/stores/unifiedCredentialStore.test.ts @@ -0,0 +1,540 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { log } from '../util/log'; +import { CredentialCache } from './credentialCache'; +import { StrategyManager } from './strategyManager'; +import UnifiedCredentialStore from './unifiedCredentialStore'; + +// Mock dependencies +const mockStrategyManager = { + supportedMethods: ['password'], + hasAnyCredentials: false, + preferredMethod: 'password', + getStrategy: vi.fn(), + getSupportedMethods: vi.fn(), + clearAll: vi.fn() +}; + +// Create a mock credential cache that actually stores values +const createMockCredentialCache = () => { + const storage = new Map(); + return { + get: vi.fn((key: string) => storage.get(key)), + set: vi.fn((key: string, value: string) => storage.set(key, value)), + clear: vi.fn(() => storage.clear()), + _storage: storage + }; +}; + +let mockCredentialCache = createMockCredentialCache(); + +// Mock strategy +const mockPasswordStrategy = { + method: 'password', + isSupported: true, + isUnlocked: false, + unlock: vi.fn(), + getCredential: vi.fn(), + setCredential: vi.fn(), + hasAnyCredentials: false, + clear: vi.fn() +}; + +// Mock constructors +vi.mock('./strategyManager', () => ({ + StrategyManager: vi.fn().mockImplementation(() => mockStrategyManager) +})); + +vi.mock('./credentialCache', () => ({ + CredentialCache: vi.fn().mockImplementation(() => mockCredentialCache) +})); + +// Mock log +vi.spyOn(log, 'info').mockImplementation(() => {}); +vi.spyOn(log, 'warn').mockImplementation(() => {}); +vi.spyOn(log, 'error').mockImplementation(() => {}); + +describe('UnifiedCredentialStore', () => { + let store: UnifiedCredentialStore; + const baseConfig = { + namespace: 'test-namespace' + }; + + beforeEach(() => { + vi.clearAllMocks(); + + // Recreate credential cache to ensure clean state + mockCredentialCache = createMockCredentialCache(); + + // Reset mock defaults + mockStrategyManager.hasAnyCredentials = false; + mockStrategyManager.preferredMethod = 'password'; + mockStrategyManager.clearAll.mockReturnValue(undefined); + mockStrategyManager.getSupportedMethods.mockReturnValue(['password']); + mockStrategyManager.getStrategy.mockReturnValue(mockPasswordStrategy); + + mockPasswordStrategy.isSupported = true; + mockPasswordStrategy.isUnlocked = false; + mockPasswordStrategy.unlock.mockResolvedValue(true); + mockPasswordStrategy.getCredential.mockResolvedValue(undefined); + mockPasswordStrategy.setCredential.mockResolvedValue(undefined); + mockPasswordStrategy.hasAnyCredentials = false; + + store = new UnifiedCredentialStore(baseConfig); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('Constructor', () => { + it('should create strategy manager and credential cache', () => { + expect(StrategyManager).toHaveBeenCalledWith(baseConfig); + expect(CredentialCache).toHaveBeenCalled(); + }); + + it('should log initialization', () => { + expect(log.info).toHaveBeenCalledWith( + '[UnifiedCredentialStore] Initialized with strategy manager' + ); + }); + }); + + describe('CredentialStore interface - getters/setters', () => { + describe('password', () => { + it('should return undefined (password not stored)', () => { + expect(store.password).toBeUndefined(); + }); + + it('should allow setting password (no-op)', () => { + expect(() => { + store.password = 'test-password'; + }).not.toThrow(); + expect(store.password).toBeUndefined(); + }); + }); + + describe('pairingPhrase', () => { + it('should return cached value', () => { + mockCredentialCache._storage.set('pairingPhrase', 'test-phrase'); + + expect(store.pairingPhrase).toBe('test-phrase'); + expect(mockCredentialCache.get).toHaveBeenCalledWith('pairingPhrase'); + }); + + it('should return empty string when no cached value', () => { + expect(store.pairingPhrase).toBe(''); + }); + + it('should set cached value', () => { + store.pairingPhrase = 'new-phrase'; + + expect(mockCredentialCache.set).toHaveBeenCalledWith( + 'pairingPhrase', + 'new-phrase' + ); + }); + }); + + describe('serverHost', () => { + it('should return cached value', () => { + mockCredentialCache._storage.set('serverHost', 'test-host:443'); + + expect(store.serverHost).toBe('test-host:443'); + expect(mockCredentialCache.get).toHaveBeenCalledWith('serverHost'); + }); + + it('should return empty string when no cached value', () => { + expect(store.serverHost).toBe(''); + }); + + it('should set cached value', () => { + store.serverHost = 'new-host:443'; + + expect(mockCredentialCache.set).toHaveBeenCalledWith( + 'serverHost', + 'new-host:443' + ); + }); + }); + + describe('localKey', () => { + it('should return cached value', () => { + mockCredentialCache._storage.set('localKey', 'test-local-key'); + + expect(store.localKey).toBe('test-local-key'); + expect(mockCredentialCache.get).toHaveBeenCalledWith('localKey'); + }); + + it('should return empty string when no cached value', () => { + expect(store.localKey).toBe(''); + }); + + it('should set cached value', () => { + store.localKey = 'new-local-key'; + + expect(mockCredentialCache.set).toHaveBeenCalledWith( + 'localKey', + 'new-local-key' + ); + }); + }); + + describe('remoteKey', () => { + it('should return cached value', () => { + mockCredentialCache._storage.set('remoteKey', 'test-remote-key'); + + expect(store.remoteKey).toBe('test-remote-key'); + expect(mockCredentialCache.get).toHaveBeenCalledWith('remoteKey'); + }); + + it('should return empty string when no cached value', () => { + expect(store.remoteKey).toBe(''); + }); + + it('should set cached value', () => { + store.remoteKey = 'new-remote-key'; + + expect(mockCredentialCache.set).toHaveBeenCalledWith( + 'remoteKey', + 'new-remote-key' + ); + }); + }); + + describe('isPaired', () => { + it('should return strategy manager hasAnyCredentials result', () => { + mockStrategyManager.hasAnyCredentials = true; + expect(store.isPaired).toBe(true); + + mockStrategyManager.hasAnyCredentials = false; + expect(store.isPaired).toBe(false); + }); + }); + }); + + describe('clear()', () => { + it('should clear cache and strategies when memoryOnly is false', () => { + store.clear(false); + + expect(mockCredentialCache.clear).toHaveBeenCalled(); + expect(mockStrategyManager.clearAll).toHaveBeenCalled(); + }); + + it('should clear cache only when memoryOnly is true', () => { + store.clear(true); + + expect(mockCredentialCache.clear).toHaveBeenCalled(); + expect(mockStrategyManager.clearAll).not.toHaveBeenCalled(); + }); + + it('should clear cache and strategies when memoryOnly is undefined', () => { + store.clear(); + + expect(mockCredentialCache.clear).toHaveBeenCalled(); + expect(mockStrategyManager.clearAll).toHaveBeenCalled(); + }); + + it('should reset unlock state', async () => { + // First unlock + await store.unlock({ method: 'password', password: 'test' }); + expect(store.isUnlocked()).toBe(true); + + // Then clear + store.clear(); + expect(store.isUnlocked()).toBe(false); + }); + }); + + describe('isUnlocked()', () => { + it('should return false initially', () => { + expect(store.isUnlocked()).toBe(false); + }); + + it('should return true after successful unlock', async () => { + mockPasswordStrategy.unlock.mockResolvedValue(true); + + await store.unlock({ method: 'password', password: 'test' }); + + expect(store.isUnlocked()).toBe(true); + }); + + it('should return false after failed unlock', async () => { + mockPasswordStrategy.unlock.mockResolvedValue(false); + + await store.unlock({ method: 'password', password: 'wrong' }); + + expect(store.isUnlocked()).toBe(false); + }); + }); + + describe('unlock()', () => { + it('should unlock with password method', async () => { + mockPasswordStrategy.unlock.mockResolvedValue(true); + + const result = await store.unlock({ + method: 'password', + password: 'test-password' + }); + + expect(result).toBe(true); + expect(mockStrategyManager.getStrategy).toHaveBeenCalledWith('password'); + expect(mockPasswordStrategy.unlock).toHaveBeenCalledWith({ + method: 'password', + password: 'test-password' + }); + }); + + it('should return false for unknown method', async () => { + mockStrategyManager.getStrategy.mockReturnValue(undefined); + + // Cast to any since 'unknown' is not a valid UnlockMethod + const result = await store.unlock({ + method: 'unknown' as any, + password: 'test' + }); + + expect(result).toBe(false); + expect(log.error).toHaveBeenCalledWith( + '[UnifiedCredentialStore] Unknown unlock method: unknown' + ); + }); + + it('should return false when method not supported', async () => { + mockPasswordStrategy.isSupported = false; + + const result = await store.unlock({ + method: 'password', + password: 'test' + }); + + expect(result).toBe(false); + expect(log.error).toHaveBeenCalledWith( + '[UnifiedCredentialStore] Unlock method not supported: password' + ); + }); + + it('should return false when unlock fails', async () => { + mockPasswordStrategy.unlock.mockResolvedValue(false); + + const result = await store.unlock({ + method: 'password', + password: 'wrong' + }); + + expect(result).toBe(false); + expect(store.isUnlocked()).toBe(false); + }); + + it('should handle unlock error', async () => { + const error = new Error('Unlock failed'); + mockPasswordStrategy.unlock.mockRejectedValue(error); + + const result = await store.unlock({ + method: 'password', + password: 'test' + }); + + expect(result).toBe(false); + expect(log.error).toHaveBeenCalledWith( + '[UnifiedCredentialStore] Unlock failed:', + error + ); + }); + + it('should load credentials to cache after successful unlock', async () => { + mockPasswordStrategy.unlock.mockResolvedValue(true); + mockPasswordStrategy.getCredential.mockImplementation((key: string) => { + const values: Record = { + localKey: 'loaded-local', + remoteKey: 'loaded-remote', + pairingPhrase: 'loaded-phrase', + serverHost: 'loaded-host' + }; + return Promise.resolve(values[key]); + }); + + await store.unlock({ method: 'password', password: 'test' }); + + expect(mockPasswordStrategy.getCredential).toHaveBeenCalledWith( + 'localKey' + ); + expect(mockPasswordStrategy.getCredential).toHaveBeenCalledWith( + 'remoteKey' + ); + expect(mockPasswordStrategy.getCredential).toHaveBeenCalledWith( + 'pairingPhrase' + ); + expect(mockPasswordStrategy.getCredential).toHaveBeenCalledWith( + 'serverHost' + ); + }); + }); + + describe('getAuthenticationInfo()', () => { + it('should return authentication info', async () => { + mockStrategyManager.hasAnyCredentials = true; + mockStrategyManager.preferredMethod = 'password'; + + const info = await store.getAuthenticationInfo(); + + expect(info).toEqual({ + isUnlocked: false, + hasStoredCredentials: true, + preferredUnlockMethod: 'password' + }); + }); + + it('should reflect unlocked state', async () => { + mockPasswordStrategy.unlock.mockResolvedValue(true); + await store.unlock({ method: 'password', password: 'test' }); + + const info = await store.getAuthenticationInfo(); + + expect(info.isUnlocked).toBe(true); + }); + }); + + describe('getSupportedUnlockMethods()', () => { + it('should return supported methods from strategy manager', () => { + mockStrategyManager.getSupportedMethods.mockReturnValue(['password']); + + const methods = store.getSupportedUnlockMethods(); + + expect(methods).toEqual(['password']); + expect(mockStrategyManager.getSupportedMethods).toHaveBeenCalled(); + }); + }); + + describe('persistCredentials()', () => { + it('should persist credentials when unlocked', async () => { + // Setup - unlock first + mockPasswordStrategy.unlock.mockResolvedValue(true); + await store.unlock({ method: 'password', password: 'test' }); + + // Set some credentials + mockCredentialCache._storage.set('localKey', 'test-local'); + mockCredentialCache._storage.set('remoteKey', 'test-remote'); + mockCredentialCache._storage.set('pairingPhrase', 'test-phrase'); + mockCredentialCache._storage.set('serverHost', 'test-host'); + + // Persist + await store.persistCredentials(); + + expect(mockPasswordStrategy.setCredential).toHaveBeenCalledWith( + 'localKey', + 'test-local' + ); + expect(mockPasswordStrategy.setCredential).toHaveBeenCalledWith( + 'remoteKey', + 'test-remote' + ); + expect(mockPasswordStrategy.setCredential).toHaveBeenCalledWith( + 'pairingPhrase', + 'test-phrase' + ); + expect(mockPasswordStrategy.setCredential).toHaveBeenCalledWith( + 'serverHost', + 'test-host' + ); + }); + + it('should warn and return when not unlocked', async () => { + await store.persistCredentials(); + + expect(log.warn).toHaveBeenCalledWith( + '[UnifiedCredentialStore] Cannot persist credentials - not unlocked' + ); + expect(mockPasswordStrategy.setCredential).not.toHaveBeenCalled(); + }); + + it('should handle missing strategy', async () => { + // Unlock first + mockPasswordStrategy.unlock.mockResolvedValue(true); + await store.unlock({ method: 'password', password: 'test' }); + + // Make strategy unavailable + mockStrategyManager.getStrategy.mockReturnValue(undefined); + + await store.persistCredentials(); + + expect(log.error).toHaveBeenCalledWith( + '[UnifiedCredentialStore] Active strategy not found' + ); + }); + + it('should propagate errors from setCredential', async () => { + // Unlock first + mockPasswordStrategy.unlock.mockResolvedValue(true); + await store.unlock({ method: 'password', password: 'test' }); + + mockCredentialCache._storage.set('localKey', 'test-local'); + + const error = new Error('Set credential failed'); + mockPasswordStrategy.setCredential.mockRejectedValue(error); + + await expect(store.persistCredentials()).rejects.toThrow( + 'Set credential failed' + ); + expect(log.error).toHaveBeenCalledWith( + '[UnifiedCredentialStore] Failed to persist credentials:', + error + ); + }); + + it('should skip credentials that are not in cache', async () => { + // Unlock first + mockPasswordStrategy.unlock.mockResolvedValue(true); + await store.unlock({ method: 'password', password: 'test' }); + + // Only set some credentials + mockCredentialCache._storage.set('localKey', 'test-local'); + // remoteKey, pairingPhrase, serverHost are not set + + await store.persistCredentials(); + + expect(mockPasswordStrategy.setCredential).toHaveBeenCalledWith( + 'localKey', + 'test-local' + ); + expect(mockPasswordStrategy.setCredential).toHaveBeenCalledTimes(1); + }); + }); + + describe('Integration tests', () => { + it('should support full authentication workflow', async () => { + // Initially not unlocked + expect(store.isUnlocked()).toBe(false); + const info1 = await store.getAuthenticationInfo(); + expect(info1.isUnlocked).toBe(false); + + // Set credentials before unlock + store.pairingPhrase = 'test-phrase'; + store.serverHost = 'test-host:443'; + + // Unlock + mockPasswordStrategy.unlock.mockResolvedValue(true); + const unlockResult = await store.unlock({ + method: 'password', + password: 'test-password' + }); + expect(unlockResult).toBe(true); + expect(store.isUnlocked()).toBe(true); + + // Check auth info after unlock + const info2 = await store.getAuthenticationInfo(); + expect(info2.isUnlocked).toBe(true); + + // Set keys received during connection + store.localKey = 'new-local-key'; + store.remoteKey = 'new-remote-key'; + + // Persist credentials + await store.persistCredentials(); + expect(mockPasswordStrategy.setCredential).toHaveBeenCalled(); + + // Clear + store.clear(); + expect(store.isUnlocked()).toBe(false); + }); + }); +}); diff --git a/lib/stores/unifiedCredentialStore.ts b/lib/stores/unifiedCredentialStore.ts new file mode 100644 index 0000000..f6165cd --- /dev/null +++ b/lib/stores/unifiedCredentialStore.ts @@ -0,0 +1,241 @@ +import { + CredentialStore, + LncConfig, + UnlockMethod, + UnlockOptions +} from '../types/lnc'; +import { log } from '../util/log'; +import { AuthStrategy } from './authStrategy'; +import { CredentialCache } from './credentialCache'; +import { StrategyManager } from './strategyManager'; + +/** + * Authentication information returned by getAuthenticationInfo() + */ +export interface AuthenticationInfo { + isUnlocked: boolean; + hasStoredCredentials: boolean; + preferredUnlockMethod: UnlockMethod; +} + +/** + * Unified credential store that uses the strategy pattern for authentication. + * Maintains the same CredentialStore interface for backward compatibility. + */ +export default class UnifiedCredentialStore implements CredentialStore { + private strategyManager: StrategyManager; + private credentialCache: CredentialCache; + private _isUnlocked = false; + private activeMethod: UnlockMethod | null = null; + + constructor(config: LncConfig) { + this.strategyManager = new StrategyManager(config); + this.credentialCache = new CredentialCache(); + + log.info('[UnifiedCredentialStore] Initialized with strategy manager'); + } + + // + // CredentialStore interface implementation + // + + get password(): string | undefined { + // Password is only available during unlock, not stored. This field is required by + // the CredentialStore interface. + log.warn( + '[UnifiedCredentialStore] Direct access to password is not supported. Use the unlock method instead.' + ); + return undefined; + } + + set password(_value: string | undefined) { + // Password is handled during unlock, not stored directly. This field is required by + // the CredentialStore interface. + log.warn( + '[UnifiedCredentialStore] Setting password directly is not supported. Use the unlock method instead.' + ); + } + + get pairingPhrase(): string { + return this.credentialCache.get('pairingPhrase') || ''; + } + + set pairingPhrase(value: string) { + this.credentialCache.set('pairingPhrase', value); + } + + get serverHost(): string { + return this.credentialCache.get('serverHost') || ''; + } + + set serverHost(value: string) { + this.credentialCache.set('serverHost', value); + } + + get localKey(): string { + return this.credentialCache.get('localKey') || ''; + } + + set localKey(value: string) { + this.credentialCache.set('localKey', value); + } + + get remoteKey(): string { + return this.credentialCache.get('remoteKey') || ''; + } + + set remoteKey(value: string) { + this.credentialCache.set('remoteKey', value); + } + + get isPaired(): boolean { + return this.strategyManager.hasAnyCredentials; + } + + clear(memoryOnly?: boolean): void { + this.credentialCache.clear(); + this._isUnlocked = false; + this.activeMethod = null; + + if (!memoryOnly) { + this.strategyManager.clearAll(); + } + + log.info('[UnifiedCredentialStore] Cleared', { memoryOnly }); + } + + // + // Enhanced authentication methods + // + + /** + * Check if any strategy is currently unlocked + */ + isUnlocked(): boolean { + return this._isUnlocked; + } + + /** + * Unlock the credential store using the specified method + */ + async unlock(options: UnlockOptions): Promise { + const strategy = this.strategyManager.getStrategy(options.method); + + if (!strategy) { + log.error( + `[UnifiedCredentialStore] Unknown unlock method: ${options.method}` + ); + return false; + } + + if (!strategy.isSupported) { + log.error( + `[UnifiedCredentialStore] Unlock method not supported: ${options.method}` + ); + return false; + } + + try { + const success = await strategy.unlock(options); + + if (success) { + this._isUnlocked = true; + this.activeMethod = options.method; + + // Load credentials from strategy into cache + await this.loadCredentialsToCache(strategy); + + log.info('[UnifiedCredentialStore] Unlocked successfully', { + method: options.method + }); + } + + return success; + } catch (error) { + log.error('[UnifiedCredentialStore] Unlock failed:', error); + return false; + } + } + + /** + * Get authentication information + */ + async getAuthenticationInfo(): Promise { + return { + isUnlocked: this._isUnlocked, + hasStoredCredentials: this.strategyManager.hasAnyCredentials, + preferredUnlockMethod: this.strategyManager.preferredMethod + }; + } + + /** + * Get supported unlock methods + */ + getSupportedUnlockMethods(): UnlockMethod[] { + return this.strategyManager.getSupportedMethods(); + } + + /** + * Persist current credentials using the active strategy. + * Should be called after successful connection to save credentials. + */ + async persistCredentials(): Promise { + if (!this._isUnlocked || !this.activeMethod) { + log.warn( + '[UnifiedCredentialStore] Cannot persist credentials - not unlocked' + ); + return; + } + + const strategy = this.strategyManager.getStrategy(this.activeMethod); + if (!strategy) { + log.error('[UnifiedCredentialStore] Active strategy not found'); + return; + } + + try { + // Persist all cached credentials to the active strategy + for (const key of [ + 'localKey', + 'remoteKey', + 'pairingPhrase', + 'serverHost' + ]) { + const value = this.credentialCache.get(key); + if (value) { + await strategy.setCredential(key, value); + } + } + + log.info('[UnifiedCredentialStore] Credentials persisted', { + method: this.activeMethod + }); + } catch (error) { + log.error( + '[UnifiedCredentialStore] Failed to persist credentials:', + error + ); + throw error; + } + } + + // + // Internal methods + // + + /** + * Load credentials from the strategy into the cache + */ + private async loadCredentialsToCache(strategy: AuthStrategy): Promise { + const keys = ['localKey', 'remoteKey', 'pairingPhrase', 'serverHost']; + + for (const key of keys) { + const value = await strategy.getCredential(key); + if (value) { + this.credentialCache.set(key, value); + } + } + + log.info('[UnifiedCredentialStore] Credentials loaded to cache'); + } +} diff --git a/lib/types/lnc.ts b/lib/types/lnc.ts index e975c30..3b486f5 100644 --- a/lib/types/lnc.ts +++ b/lib/types/lnc.ts @@ -105,6 +105,17 @@ export interface LncConfig { * browser's `localStorage` */ credentialStore?: CredentialStore; + /** + * When true, uses the new UnifiedCredentialStore with strategy-based + * authentication instead of the legacy LncCredentialStore. + * This enables password authentication via the new architecture. + * Default is false for backward compatibility. + * + * This is a temporary flag to enable the new password flow. It will be removed + * in a future PR when Passkeys are implemented. It is added here just to enable + * testing in the demo app. + */ + useUnifiedStore?: boolean; } /**