fix(encryption): fix cache key bug and add missing tests

Fix critical bug in decryptBatch where salt.buffer returned the entire
ArrayBuffer instead of just the 16-byte salt, defeating key caching.

- Use salt.slice().buffer to create proper cache key
- Add tests for encryptPayload() and decryptPayload() methods
- Fix test assertion to verify reference identity with toBe()
This commit is contained in:
Claude 2026-01-24 00:20:25 +00:00
parent 6c7d1668f9
commit 39f95015ee
No known key found for this signature in database
2 changed files with 45 additions and 4 deletions

View file

@ -359,7 +359,9 @@ export const decryptBatch = async (
// Argon2 format: extract salt and use cached key
const salt = new Uint8Array(dataBuffer, 0, SALT_LENGTH);
const saltKey = ab2base64(salt.buffer);
// IMPORTANT: Use slice() to create a copy with its own ArrayBuffer.
// Without slice(), salt.buffer returns the entire dataBuffer, not just the salt!
const saltKey = ab2base64(salt.slice().buffer);
// Check cache for this salt
let keyInfo = keyCache.get(saltKey);

View file

@ -191,7 +191,7 @@ describe('OperationEncryptionService', () => {
expect(decrypted).toEqual([]);
});
it('should return all operations unchanged when none are encrypted', async () => {
it('should return same array reference when none are encrypted', async () => {
const ops = [
createMockSyncOp({ title: 'Plain 1' }),
createMockSyncOp({ title: 'Plain 2' }),
@ -200,9 +200,9 @@ describe('OperationEncryptionService', () => {
const result = await service.decryptOperations(ops, TEST_PASSWORD);
// Should return the same array (or equivalent)
// Should return the same array reference (optimization: no copying needed)
expect(result).toBe(ops);
expect(result.length).toBe(3);
expect(result).toEqual(ops);
});
it('should throw DecryptError for malformed encrypted operation (non-string payload)', async () => {
@ -229,6 +229,45 @@ describe('OperationEncryptionService', () => {
});
});
describe('encryptPayload / decryptPayload', () => {
it('should encrypt and decrypt an object payload', async () => {
const payload = { foo: 'bar', count: 42 };
const encrypted = await service.encryptPayload(payload, TEST_PASSWORD);
expect(typeof encrypted).toBe('string');
expect(encrypted).not.toContain('foo'); // Should be encrypted
const decrypted = await service.decryptPayload(encrypted, TEST_PASSWORD);
expect(decrypted).toEqual(payload);
});
it('should encrypt and decrypt an array payload', async () => {
const payload = [1, 2, 3, { nested: true }];
const encrypted = await service.encryptPayload(payload, TEST_PASSWORD);
const decrypted = await service.decryptPayload<typeof payload>(
encrypted,
TEST_PASSWORD,
);
expect(decrypted).toEqual(payload);
});
it('should throw DecryptError for wrong password', async () => {
const payload = { secret: 'data' };
const encrypted = await service.encryptPayload(payload, TEST_PASSWORD);
await expectAsync(
service.decryptPayload(encrypted, 'wrong-password'),
).toBeRejectedWithError(DecryptError);
});
it('should throw DecryptError for corrupted ciphertext', async () => {
await expectAsync(
service.decryptPayload('invalid-ciphertext!!!', TEST_PASSWORD),
).toBeRejectedWithError(DecryptError);
});
});
describe('round-trip with various payload types', () => {
it('should handle null payload', async () => {
const op = createMockSyncOp(null);