Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions lib/encryption/encryptionService.ts
Original file line number Diff line number Diff line change
@@ -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<string>;

/**
* Decrypt data using the current encryption key
*/
decrypt(data: string): Promise<string>;

/**
* Unlock the encryption service with the provided options
*/
unlock(options: UnlockOptions): Promise<void>;

/**
* 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<boolean>;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see in the credentialRepository we have an interface, followed by a base class. There's a different pattern in encryptionService which is just an interface, which passwordEncryptionService extends. Why have the two patterns?

Copy link
Member Author

@jamaljsr jamaljsr Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did this because CredentialRepository has a base class with shared functionality. I put the base class in the same file with the interface. The EncryptionService doesn't have this inheritance structure.

158 changes: 158 additions & 0 deletions lib/encryption/passwordEncryptionService.test.ts
Original file line number Diff line number Diff line change
@@ -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'
);
});
});
});
140 changes: 140 additions & 0 deletions lib/encryption/passwordEncryptionService.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<string> {
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<void> {
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();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this part of the code, we generate a salt for a new user.

Does it make sense to do this in the unlock method? Would it be better to do this outside of the scope of unlock, which in my opinion might be more ergonomic? Perhaps a new method like below would make sense?

  /**
   * Initialize the service for a new user (generates salt, etc.)
   */
  initialize(options: InitializeOptions): Promise<void>;

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's better here so consumers only call one method to unlock the encrypted data. It makes the public API easier to understand IMO. Don't set the salt field for new users and set it for returning users, versus having two different methods to call.

}

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<boolean> {
// 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 {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this method needed? Is it for testing?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is used to verify the password is valid before trying to decrypt the actual data. We do this currently in master, see the credentialStore. It is possible to decrypt data using the wrong password but it won't throw an error, it'll just return garbage unencrypted data. So what we do is use TEST_DATA, which is a string that we hard-code. When a new user creates a password, we encrypt the TEST_DATA and store that in localStorage. Take a look in your browser localStorage after pairing and you'll see cipher in there along with the rest of the data. Now when a returning user enter's their password, we first try to decrypt the cipher and compare the returned value to TEST_DATA. If this matches, then we know the password is correct.

if (!this.password || !this.salt) {
throw new Error('No password/salt available - unlock first');
}
return createTestCipher(this.password, this.salt);
}
}
Loading