diff --git a/lib/encryption/encryptionService.ts b/lib/encryption/encryptionService.ts new file mode 100644 index 0000000..8d467e7 --- /dev/null +++ b/lib/encryption/encryptionService.ts @@ -0,0 +1,46 @@ +import { UnlockMethod, UnlockOptions } from '../types/lnc'; + +/** + * Pure encryption service interface - no storage dependencies + */ +export interface EncryptionService { + /** + * Get the method this encryption service handles + */ + get method(): UnlockMethod; + + /** + * Check if the encryption service is currently unlocked + */ + get isUnlocked(): boolean; + + /** + * Encrypt data using the current encryption key + */ + encrypt(data: string): Promise; + + /** + * Decrypt data using the current encryption key + */ + decrypt(data: string): Promise; + + /** + * Unlock the encryption service with the provided options + */ + unlock(options: UnlockOptions): Promise; + + /** + * Lock the encryption service (clear sensitive data) + */ + lock(): void; + + /** + * Check if this service can handle the given unlock method + */ + canHandle(method: UnlockMethod): boolean; + + /** + * Check if this service has stored credentials/data available + */ + hasStoredData(): Promise; +} diff --git a/lib/encryption/passwordEncryptionService.test.ts b/lib/encryption/passwordEncryptionService.test.ts new file mode 100644 index 0000000..dfc685c --- /dev/null +++ b/lib/encryption/passwordEncryptionService.test.ts @@ -0,0 +1,158 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { createTestCipher, generateSalt } from '../util/encryption'; +import { PasswordEncryptionService } from './passwordEncryptionService'; + +describe('PasswordEncryptionService', () => { + let service: PasswordEncryptionService; + + beforeEach(() => { + service = new PasswordEncryptionService(); + }); + + describe('constructor', () => { + it('should create a locked service when no password is provided', () => { + expect(service.isUnlocked).toBe(false); + }); + }); + + describe('unlock', () => { + it('should unlock with a new password (no salt)', async () => { + await service.unlock({ method: 'password', password: 'testPassword' }); + expect(service.isUnlocked).toBe(true); + }); + + it('should unlock with existing salt and cipher', async () => { + const password = 'testPassword'; + const salt = generateSalt(); + const cipher = createTestCipher(password, salt); + + await service.unlock({ method: 'password', password, salt, cipher }); + expect(service.isUnlocked).toBe(true); + }); + + it('should throw error for invalid password with existing cipher', async () => { + const correctPassword = 'correctPassword'; + const wrongPassword = 'wrongPassword'; + const salt = generateSalt(); + const cipher = createTestCipher(correctPassword, salt); + + await expect( + service.unlock({ + method: 'password', + password: wrongPassword, + salt, + cipher + }) + ).rejects.toThrow('Invalid password'); + }); + + it('should throw error if password is missing', async () => { + await expect( + service.unlock({ method: 'password', password: '' }) + ).rejects.toThrow('Password is required for password unlock'); + }); + + it('should throw error for non-password unlock method', async () => { + // Type assertion to test runtime behavior with invalid input + await expect( + service.unlock({ method: 'passkey' as 'password', password: 'test' }) + ).rejects.toThrow( + 'Password encryption service requires password unlock method' + ); + }); + }); + + describe('encrypt/decrypt', () => { + beforeEach(async () => { + await service.unlock({ method: 'password', password: 'testPassword' }); + }); + + it('should encrypt and decrypt data correctly', async () => { + const plaintext = 'Hello, World!'; + const encrypted = await service.encrypt(plaintext); + const decrypted = await service.decrypt(encrypted); + expect(decrypted).toBe(plaintext); + }); + + it('should throw error when encrypting while locked', async () => { + service.lock(); + await expect(service.encrypt('test')).rejects.toThrow( + 'Encryption service is locked. Call unlock() first.' + ); + }); + + it('should throw error when decrypting while locked', async () => { + const encrypted = await service.encrypt('test'); + service.lock(); + await expect(service.decrypt(encrypted)).rejects.toThrow( + 'Encryption service is locked. Call unlock() first.' + ); + }); + }); + + describe('lock', () => { + it('should clear password and salt when locked', async () => { + await service.unlock({ method: 'password', password: 'testPassword' }); + expect(service.isUnlocked).toBe(true); + service.lock(); + expect(service.isUnlocked).toBe(false); + }); + }); + + describe('getMethod', () => { + it('should return password as the method', () => { + expect(service.method).toBe('password'); + }); + }); + + describe('canHandle', () => { + it('should return true for password method', () => { + expect(service.canHandle('password')).toBe(true); + }); + + it('should return false for non-password methods', () => { + // Type assertion to test runtime behavior + expect(service.canHandle('passkey' as 'password')).toBe(false); + expect(service.canHandle('session' as 'password')).toBe(false); + }); + }); + + describe('hasStoredData', () => { + it('should always return false (storage is at repository layer)', async () => { + expect(await service.hasStoredData()).toBe(false); + await service.unlock({ method: 'password', password: 'test' }); + expect(await service.hasStoredData()).toBe(false); + }); + }); + + describe('getSalt', () => { + it('should return the salt when unlocked', async () => { + await service.unlock({ method: 'password', password: 'testPassword' }); + const salt = service.getSalt(); + expect(salt).toBeDefined(); + expect(typeof salt).toBe('string'); + expect(salt.length).toBe(32); + }); + + it('should throw error when locked', () => { + expect(() => service.getSalt()).toThrow( + 'No salt available - unlock first' + ); + }); + }); + + describe('createTestCipher', () => { + it('should create a test cipher when unlocked', async () => { + await service.unlock({ method: 'password', password: 'testPassword' }); + const cipher = service.createTestCipher(); + expect(cipher).toBeDefined(); + expect(typeof cipher).toBe('string'); + }); + + it('should throw error when locked', () => { + expect(() => service.createTestCipher()).toThrow( + 'No password/salt available - unlock first' + ); + }); + }); +}); diff --git a/lib/encryption/passwordEncryptionService.ts b/lib/encryption/passwordEncryptionService.ts new file mode 100644 index 0000000..f36405a --- /dev/null +++ b/lib/encryption/passwordEncryptionService.ts @@ -0,0 +1,140 @@ +import { UnlockMethod, UnlockOptions } from '../types/lnc'; +import { + createTestCipher, + decrypt, + encrypt, + generateSalt, + verifyTestCipher +} from '../util/encryption'; +import { EncryptionService } from './encryptionService'; + +/** + * Pure password-based encryption service. + * No storage dependencies - just crypto operations. + */ +export class PasswordEncryptionService implements EncryptionService { + private password?: string; + private salt?: string; + private isUnlockedState: boolean = false; + + /** + * Get the unlock method handled by this service (`password`). + */ + get method(): UnlockMethod { + return 'password'; + } + + /** + * Returns true when a password and salt are set and the service is unlocked. + */ + get isUnlocked(): boolean { + return this.isUnlockedState && !!this.password && !!this.salt; + } + + /** + * Encrypt a plaintext string using the currently unlocked password and salt. + * Throws if the service has not been unlocked. + */ + async encrypt(data: string): Promise { + if (!this.isUnlocked || !this.password || !this.salt) { + throw new Error('Encryption service is locked. Call unlock() first.'); + } + return encrypt(data, this.password, this.salt); + } + + /** + * Decrypt a ciphertext string using the currently unlocked password and salt. + * Throws if the service has not been unlocked. + */ + async decrypt(data: string): Promise { + if (!this.isUnlocked || !this.password || !this.salt) { + throw new Error('Encryption service is locked. Call unlock() first.'); + } + return decrypt(data, this.password, this.salt); + } + + /** + * Unlock the service with the given password (and optional salt/cipher). + * For existing users, verifies the password using the stored test cipher. + */ + async unlock(options: UnlockOptions): Promise { + if (options.method !== 'password') { + throw new Error( + 'Password encryption service requires password unlock method' + ); + } + + if (!options.password) { + throw new Error('Password is required for password unlock'); + } + + this.password = options.password; + + // If salt is provided (existing user), use it + if (options.salt) { + this.salt = options.salt; + + // Verify password is correct by checking test cipher + if (options.cipher) { + try { + if (!verifyTestCipher(options.cipher, this.password, this.salt)) { + throw new Error('Invalid password'); + } + } catch { + throw new Error('Invalid password'); + } + } + } else { + // New user - generate new salt + this.salt = generateSalt(); + } + + this.isUnlockedState = true; + } + + /** + * Clear all in-memory password material and reset the unlocked state. + */ + lock(): void { + this.password = undefined; + this.salt = undefined; + this.isUnlockedState = false; + } + + /** + * Return true if this service can handle the provided unlock method. + */ + canHandle(method: UnlockMethod): boolean { + return method === 'password'; + } + + /** + * Return whether this service has any stored data of its own. + * Always false; storage is handled at the repository layer. + */ + async hasStoredData(): Promise { + // Password encryption itself doesn't store data + // The repository layer will check for stored salt/cipher + return false; + } + + /** + * Get the current salt (for storage by repository) + */ + getSalt(): string { + if (!this.salt) { + throw new Error('No salt available - unlock first'); + } + return this.salt; + } + + /** + * Generate a test cipher (for storage by repository) + */ + createTestCipher(): string { + if (!this.password || !this.salt) { + throw new Error('No password/salt available - unlock first'); + } + return createTestCipher(this.password, this.salt); + } +} diff --git a/lib/repositories/credentialRepository.test.ts b/lib/repositories/credentialRepository.test.ts new file mode 100644 index 0000000..3ed3f25 --- /dev/null +++ b/lib/repositories/credentialRepository.test.ts @@ -0,0 +1,189 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { UnlockOptions } from '../types/lnc'; +import { BaseCredentialRepository } from './credentialRepository'; + +/** + * Concrete implementation of BaseCredentialRepository for testing + */ +class TestCredentialRepository extends BaseCredentialRepository { + private unlocked = false; + + async getCredential(key: string): Promise { + return this.get(key); + } + + async setCredential(key: string, value: string): Promise { + if (!this.unlocked) { + throw new Error('Repository is locked'); + } + this.set(key, value); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async unlock(_options: UnlockOptions): Promise { + this.unlocked = true; + } + + get isUnlocked(): boolean { + return this.unlocked; + } + + lock(): void { + this.unlocked = false; + } +} + +describe('BaseCredentialRepository', () => { + let repository: TestCredentialRepository; + + beforeEach(() => { + localStorage.clear(); + repository = new TestCredentialRepository('test-namespace'); + }); + + describe('credential storage', () => { + it('should store and retrieve credentials', async () => { + await repository.unlock({ method: 'password', password: 'test' }); + await repository.setCredential('key1', 'value1'); + expect(await repository.getCredential('key1')).toBe('value1'); + }); + + it('should return undefined for non-existent credentials', async () => { + expect(await repository.getCredential('nonexistent')).toBeUndefined(); + }); + + it('should check if credential exists', async () => { + await repository.unlock({ method: 'password', password: 'test' }); + expect(repository.hasCredential('key1')).toBe(false); + await repository.setCredential('key1', 'value1'); + expect(repository.hasCredential('key1')).toBe(true); + }); + + it('should check if any credentials exist', async () => { + expect(repository.hasAnyCredentials).toBe(false); + await repository.unlock({ method: 'password', password: 'test' }); + await repository.setCredential('key1', 'value1'); + expect(repository.hasAnyCredentials).toBe(true); + }); + + it('should remove credentials', async () => { + await repository.unlock({ method: 'password', password: 'test' }); + await repository.setCredential('key1', 'value1'); + expect(repository.hasCredential('key1')).toBe(true); + await repository.removeCredential('key1'); + expect(repository.hasCredential('key1')).toBe(false); + }); + + it('should clear all credentials', async () => { + await repository.unlock({ method: 'password', password: 'test' }); + await repository.setCredential('key1', 'value1'); + await repository.setCredential('key2', 'value2'); + expect(repository.hasAnyCredentials).toBe(true); + repository.clear(); + expect(repository.hasAnyCredentials).toBe(false); + }); + }); + + describe('localStorage persistence', () => { + it('should persist credentials to localStorage', async () => { + await repository.unlock({ method: 'password', password: 'test' }); + await repository.setCredential('key1', 'value1'); + + const stored = localStorage.getItem('lnc-web:test-namespace'); + expect(stored).not.toBeNull(); + const parsed = JSON.parse(stored!); + expect(parsed.key1).toBe('value1'); + }); + + it('should load credentials from localStorage', async () => { + localStorage.setItem( + 'lnc-web:test-namespace', + JSON.stringify({ key1: 'value1' }) + ); + + const newRepository = new TestCredentialRepository('test-namespace'); + expect(await newRepository.getCredential('key1')).toBe('value1'); + }); + + it('should remove localStorage key when all credentials are cleared', async () => { + await repository.unlock({ method: 'password', password: 'test' }); + await repository.setCredential('key1', 'value1'); + expect(localStorage.getItem('lnc-web:test-namespace')).not.toBeNull(); + repository.clear(); + expect(localStorage.getItem('lnc-web:test-namespace')).toBeNull(); + }); + + it('should handle invalid JSON in localStorage gracefully', async () => { + localStorage.setItem('lnc-web:test-namespace', 'invalid-json'); + const newRepository = new TestCredentialRepository('test-namespace'); + // Should not throw, just log error and return undefined + expect(await newRepository.getCredential('key1')).toBeUndefined(); + }); + }); + + describe('unlock/lock', () => { + it('should unlock the repository', async () => { + expect(repository.isUnlocked).toBe(false); + await repository.unlock({ method: 'password', password: 'test' }); + expect(repository.isUnlocked).toBe(true); + }); + + it('should lock the repository', async () => { + await repository.unlock({ method: 'password', password: 'test' }); + expect(repository.isUnlocked).toBe(true); + repository.lock(); + expect(repository.isUnlocked).toBe(false); + }); + + it('should throw when setting credential while locked', async () => { + await expect(repository.setCredential('key1', 'value1')).rejects.toThrow( + 'Repository is locked' + ); + }); + }); + + describe('namespace isolation', () => { + it('should isolate credentials by namespace', async () => { + const repo1 = new TestCredentialRepository('namespace1'); + const repo2 = new TestCredentialRepository('namespace2'); + + await repo1.unlock({ method: 'password', password: 'test' }); + await repo2.unlock({ method: 'password', password: 'test' }); + + await repo1.setCredential('key1', 'value1'); + await repo2.setCredential('key1', 'value2'); + + expect(await repo1.getCredential('key1')).toBe('value1'); + expect(await repo2.getCredential('key1')).toBe('value2'); + }); + }); + + describe('localStorage unavailable', () => { + it('should handle localStorage being undefined', async () => { + const originalLocalStorage = globalThis.localStorage; + // @ts-expect-error - testing undefined localStorage + delete globalThis.localStorage; + + const repo = new TestCredentialRepository('test-namespace'); + await repo.unlock({ method: 'password', password: 'test' }); + + // Should not throw when localStorage is unavailable + expect(() => repo.hasAnyCredentials).not.toThrow(); + expect(repo.hasAnyCredentials).toBe(false); + + // Credentials should be set and retrieved + repo.setCredential('key1', 'value1'); + expect(await repo.getCredential('key1')).toBe('value1'); + + // Credentials should be removed + await repo.removeCredential('key1'); + expect(await repo.getCredential('key1')).toBeUndefined(); + + // Credentials should be cleared + repo.clear(); + expect(repo.hasAnyCredentials).toBe(false); + + globalThis.localStorage = originalLocalStorage; + }); + }); +}); diff --git a/lib/repositories/credentialRepository.ts b/lib/repositories/credentialRepository.ts new file mode 100644 index 0000000..cde2c1d --- /dev/null +++ b/lib/repositories/credentialRepository.ts @@ -0,0 +1,195 @@ +import { UnlockOptions } from '../types/lnc'; +import { log } from '../util/log'; + +/** + * Interface for credential repositories that manage how encrypted credentials are stored + * and retrieved. The base class handles localStorage operations (serializing all + * credentials under a single namespaced key) while subclasses manage the unlock/lock + * lifecycle specific to their encryption method. + */ +export interface CredentialRepository { + /** + * Check if the repository is unlocked + */ + get isUnlocked(): boolean; + + /** + * Check if any credentials exist (sync for performance) + */ + get hasAnyCredentials(): boolean; + + /** + * Get a decrypted credential value (async due to encryption/decryption) + */ + getCredential(key: string): Promise; + + /** + * Set an encrypted credential value (async due to encryption) + */ + setCredential(key: string, value: string): Promise; + + /** + * Remove a credential (async for consistency and future extensibility) + * Default implementation provided in BaseCredentialRepository + */ + removeCredential(key: string): Promise; + + /** + * Check if a credential exists (sync for performance) + */ + hasCredential(key: string): boolean; + + /** + * Unlock the repository for encryption/decryption + */ + unlock(options: UnlockOptions): Promise; + + /** + * Lock the repository (clear sensitive data) + */ + lock(): void; + + /** + * Clear all stored credentials + */ + clear(): void; +} + +const STORAGE_PREFIX = 'lnc-web:'; + +/** + * Base class for credential repositories with common localStorage functionality + */ +export abstract class BaseCredentialRepository implements CredentialRepository { + /** + * The credentials stored in memory for quick access after loading from storage + */ + private credentials: Map = new Map(); + + constructor(protected namespace: string) {} + + // + // Abstract methods that must be implemented by concrete repositories + // + + abstract get isUnlocked(): boolean; + + abstract getCredential(key: string): Promise; + abstract setCredential(key: string, value: string): Promise; + abstract unlock(options: UnlockOptions): Promise; + abstract lock(): void; + + // + // Public methods that can be overridden by concrete repositories + // + + /** + * Check if any credentials are stored + */ + get hasAnyCredentials(): boolean { + return this.loadedCredentials.size > 0; + } + + /** + * Remove a credential by key. This is async for consistency and future extensibility. + */ + async removeCredential(key: string): Promise { + this.remove(key); + } + + /** + * Check if a credential exists by key + */ + hasCredential(key: string): boolean { + return this.loadedCredentials.has(key); + } + + /** + * Clear all credentials + */ + clear(): void { + this.credentials.clear(); + this.save(); + } + + // + // Private methods + // + + /** + * Get the storage key for the repository + */ + private get storageKey() { + return `${STORAGE_PREFIX}${this.namespace}`; + } + + /** + * Get the credentials, loading them from storage if they are not already loaded + */ + private get loadedCredentials() { + if (this.credentials.size === 0) this.load(); + return this.credentials; + } + + /** + * Get a credential by key + */ + protected get(key: string): string | undefined { + return this.loadedCredentials.get(key) ?? undefined; + } + + /** + * Set a credential by key + */ + protected set(key: string, value: string): void { + this.credentials.set(key, value); + this.save(); + } + + /** + * Remove a credential by key + */ + protected remove(key: string): void { + this.credentials.delete(key); + this.save(); + } + + /** + * Save all credentials to storage as a JSON string under a single key + */ + private save() { + // do nothing if localStorage is not available on the backend + if (typeof globalThis.localStorage === 'undefined') return; + + if (this.credentials.size === 0) { + globalThis.localStorage.removeItem(this.storageKey); + return; + } + + const data = Object.fromEntries(this.credentials.entries()); + log.info('[CredentialRepository] saving credentials to localStorage'); + globalThis.localStorage.setItem(this.storageKey, JSON.stringify(data)); + } + + /** + * Load all credentials from storage as a JSON string under a single key + */ + private load() { + // do nothing if localStorage is not available + if (typeof globalThis.localStorage === 'undefined') return; + + const cached = globalThis.localStorage.getItem(this.storageKey); + if (cached) { + try { + const data = JSON.parse(cached); + this.credentials = new Map(Object.entries(data)); + log.info('[CredentialRepository] loaded credentials from localStorage'); + } catch (error) { + log.error( + `Failed to parse cached credentials for ${this.namespace}:`, + error + ); + } + } + } +} diff --git a/lib/repositories/passwordCredentialRepository.test.ts b/lib/repositories/passwordCredentialRepository.test.ts new file mode 100644 index 0000000..5eaaed0 --- /dev/null +++ b/lib/repositories/passwordCredentialRepository.test.ts @@ -0,0 +1,222 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { PasswordEncryptionService } from '../encryption/passwordEncryptionService'; +import { PasswordCredentialRepository } from './passwordCredentialRepository'; + +describe('PasswordCredentialRepository', () => { + let repository: PasswordCredentialRepository; + let encryptionService: PasswordEncryptionService; + + beforeEach(() => { + localStorage.clear(); + encryptionService = new PasswordEncryptionService(); + repository = new PasswordCredentialRepository( + 'test-password-repo', + encryptionService + ); + }); + + describe('unlock', () => { + it('should unlock with a new password and store salt/cipher', async () => { + await repository.unlock({ method: 'password', password: 'testPassword' }); + expect(repository.isUnlocked).toBe(true); + expect(repository.hasCredential('salt')).toBe(true); + expect(repository.hasCredential('cipher')).toBe(true); + }); + + it('should unlock with existing password and verify cipher', async () => { + // First unlock to create salt/cipher + await repository.unlock({ method: 'password', password: 'testPassword' }); + repository.lock(); + + // Create new instances to simulate page reload + const newEncryption = new PasswordEncryptionService(); + const newRepository = new PasswordCredentialRepository( + 'test-password-repo', + newEncryption + ); + + // Second unlock should verify against stored cipher + await newRepository.unlock({ + method: 'password', + password: 'testPassword' + }); + expect(newRepository.isUnlocked).toBe(true); + }); + + it('should throw error for wrong password with existing cipher', async () => { + // First unlock to create salt/cipher + await repository.unlock({ + method: 'password', + password: 'correctPassword' + }); + repository.lock(); + + // Create new instances to simulate page reload + const newEncryption = new PasswordEncryptionService(); + const newRepository = new PasswordCredentialRepository( + 'test-password-repo', + newEncryption + ); + + // Second unlock with wrong password should fail + await expect( + newRepository.unlock({ method: 'password', password: 'wrongPassword' }) + ).rejects.toThrow('Invalid password'); + }); + + it('should throw error for non-password unlock method', async () => { + await expect( + repository.unlock({ method: 'passkey' as 'password', password: 'test' }) + ).rejects.toThrow('Password repository requires password unlock method'); + }); + }); + + describe('credential operations', () => { + beforeEach(async () => { + await repository.unlock({ method: 'password', password: 'testPassword' }); + }); + + it('should encrypt and store credentials', async () => { + await repository.setCredential('localKey', 'myLocalKeyValue'); + + // The stored value should be encrypted + const stored = localStorage.getItem('lnc-web:test-password-repo'); + const parsed = JSON.parse(stored!); + expect(parsed.localKey).not.toBe('myLocalKeyValue'); + expect(parsed.localKey).toBeDefined(); + }); + + it('should decrypt and retrieve credentials', async () => { + await repository.setCredential('localKey', 'myLocalKeyValue'); + const retrieved = await repository.getCredential('localKey'); + expect(retrieved).toBe('myLocalKeyValue'); + }); + + it('should return undefined for non-existent credentials', async () => { + const retrieved = await repository.getCredential('nonexistent'); + expect(retrieved).toBeUndefined(); + }); + + it('should return undefined when decryption fails', async () => { + // Store an invalid encrypted value + localStorage.setItem( + 'lnc-web:test-password-repo', + JSON.stringify({ + salt: 'someSalt', + cipher: 'someCipher', + badKey: 'not-valid-encrypted-data' + }) + ); + + const newEncryption = new PasswordEncryptionService(); + const newRepository = new PasswordCredentialRepository( + 'test-password-repo', + newEncryption + ); + + // Manually unlock the encryption service (bypass cipher check for this test) + await newEncryption.unlock({ + method: 'password', + password: 'test' + }); + + const retrieved = await newRepository.getCredential('badKey'); + expect(retrieved).toBeUndefined(); + }); + + it('should throw error when setting credential while locked', async () => { + repository.lock(); + await expect(repository.setCredential('key', 'value')).rejects.toThrow( + 'Repository is locked. Call unlock() first.' + ); + }); + }); + + describe('lock', () => { + it('should lock the repository', async () => { + await repository.unlock({ method: 'password', password: 'testPassword' }); + expect(repository.isUnlocked).toBe(true); + repository.lock(); + expect(repository.isUnlocked).toBe(false); + }); + }); + + describe('hasStoredAuthData', () => { + it('should return false when no auth data is stored', () => { + expect(repository.hasStoredAuthData).toBe(false); + }); + + it('should return true when salt and cipher are stored', async () => { + await repository.unlock({ method: 'password', password: 'testPassword' }); + expect(repository.hasStoredAuthData).toBe(true); + }); + + it('should return false when only salt is stored', () => { + localStorage.setItem( + 'lnc-web:test-password-repo', + JSON.stringify({ salt: 'someSalt' }) + ); + const newRepository = new PasswordCredentialRepository( + 'test-password-repo', + new PasswordEncryptionService() + ); + expect(newRepository.hasStoredAuthData).toBe(false); + }); + + it('should return false when only cipher is stored', () => { + localStorage.setItem( + 'lnc-web:test-password-repo', + JSON.stringify({ cipher: 'someCipher' }) + ); + const newRepository = new PasswordCredentialRepository( + 'test-password-repo', + new PasswordEncryptionService() + ); + expect(newRepository.hasStoredAuthData).toBe(false); + }); + }); + + describe('persistence across page reloads', () => { + it('should persist and retrieve credentials after reload', async () => { + // Initial setup and store credential + await repository.unlock({ method: 'password', password: 'testPassword' }); + await repository.setCredential('localKey', 'myLocalKeyValue'); + await repository.setCredential('remoteKey', 'myRemoteKeyValue'); + + // Simulate page reload by creating new instances + const newEncryption = new PasswordEncryptionService(); + const newRepository = new PasswordCredentialRepository( + 'test-password-repo', + newEncryption + ); + + // Unlock with same password + await newRepository.unlock({ + method: 'password', + password: 'testPassword' + }); + + // Should retrieve the same values + expect(await newRepository.getCredential('localKey')).toBe( + 'myLocalKeyValue' + ); + expect(await newRepository.getCredential('remoteKey')).toBe( + 'myRemoteKeyValue' + ); + }); + }); + + describe('clear', () => { + it('should clear all credentials including auth data', async () => { + await repository.unlock({ method: 'password', password: 'testPassword' }); + await repository.setCredential('localKey', 'myLocalKeyValue'); + expect(repository.hasStoredAuthData).toBe(true); + expect(repository.hasCredential('localKey')).toBe(true); + + repository.clear(); + + expect(repository.hasStoredAuthData).toBe(false); + expect(repository.hasCredential('localKey')).toBe(false); + }); + }); +}); diff --git a/lib/repositories/passwordCredentialRepository.ts b/lib/repositories/passwordCredentialRepository.ts new file mode 100644 index 0000000..a75cb34 --- /dev/null +++ b/lib/repositories/passwordCredentialRepository.ts @@ -0,0 +1,99 @@ +import { PasswordEncryptionService } from '../encryption/passwordEncryptionService'; +import { UnlockOptions } from '../types/lnc'; +import { log } from '../util/log'; +import { BaseCredentialRepository } from './credentialRepository'; + +/** + * Password-based credential repository. + * Uses localStorage for storage and PasswordEncryptionService for encryption. + */ +export class PasswordCredentialRepository extends BaseCredentialRepository { + constructor( + namespace: string, + private encryption: PasswordEncryptionService + ) { + super(namespace); + } + + /** + * Returns true when the underlying encryption service is unlocked. + */ + get isUnlocked(): boolean { + return this.encryption.isUnlocked; + } + + /** + * Check if this repository has stored authentication data (salt/cipher) + */ + get hasStoredAuthData(): boolean { + return this.hasCredential('salt') && this.hasCredential('cipher'); + } + + /** + * Unlock the underlying encryption service using the provided password, + * loading any stored salt/cipher from localStorage. + */ + async unlock(options: UnlockOptions): Promise { + if (options.method !== 'password') { + throw new Error('Password repository requires password unlock method'); + } + + // Load salt and cipher from localStorage directly + const salt = this.get('salt'); + const cipher = this.get('cipher'); + + await this.encryption.unlock({ + method: 'password', + password: options.password, + salt, + cipher + }); + + // If first time (no salt), store salt and test cipher + if (!salt) { + this.set('salt', this.encryption.getSalt()); + this.set('cipher', this.encryption.createTestCipher()); + } + } + + /** + * Load and decrypt a credential value by key, or return undefined + * if no value is stored or decryption fails. + */ + async getCredential(key: string): Promise { + const encrypted = this.get(key); + if (!encrypted) { + log.debug( + `No encrypted credential found for ${key} in ${this.namespace}` + ); + return undefined; + } + + try { + return await this.encryption.decrypt(encrypted); + } catch (error) { + log.error(`Failed to decrypt credential ${key}:`, error); + return undefined; + } + } + + /** + * Encrypt and persist a credential value under the given key. + * Throws if the repository has not been unlocked. + */ + async setCredential(key: string, value: string): Promise { + if (!this.encryption.isUnlocked) { + throw new Error('Repository is locked. Call unlock() first.'); + } + + const encrypted = await this.encryption.encrypt(value); + this.set(key, encrypted); + } + + /** + * Lock the underlying encryption service and clear in-memory key material. + */ + lock(): void { + this.encryption.lock(); + } +} diff --git a/lib/types/lnc.ts b/lib/types/lnc.ts index 14876ca..e975c30 100644 --- a/lib/types/lnc.ts +++ b/lib/types/lnc.ts @@ -107,6 +107,21 @@ export interface LncConfig { credentialStore?: CredentialStore; } +/** + * Available unlock methods + */ +export type UnlockMethod = 'password'; + +/** + * Unlock options for different authentication methods + */ +export type UnlockOptions = { + method: 'password'; + password: string; + salt?: string; + cipher?: string; +}; + /** * The interface that must be implemented to provide `LNC` instances with storage * for its persistent data. These fields will be read and written to during the