Files
yaoweiprcandClaude Sonnet 5.5 e2bc39e1a3 feat(insomnia-data): add field encryption core (phase 1)
Value encrypt/decrypt with node:crypto AES-256-GCM, the enc:insomnia:v1:
value format and ciphertext well-formedness check, encrypted path walking,
and createFieldEncryptionHooks() for NeDB serialization hooks. The read hook
never throws and never drops a document; failed decryptions keep the original
value and are recorded in a failure report. The write hook never writes
plaintext for an encrypted field.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 15:54:51 +08:00

126 lines
5.5 KiB
TypeScript

import { createSecretKey, generateKeySync, randomBytes } from 'node:crypto';
import { describe, expect, it } from 'vitest';
import {
assertFieldEncryptionKey,
decryptValue,
ENCRYPTED_VALUE_PREFIX,
encryptValue,
isWellFormedCiphertext,
} from './value';
const key = generateKeySync('aes', { length: 256 });
const b64 = (bytes: number) => randomBytes(bytes).toString('base64');
describe('encryptValue() / decryptValue()', () => {
it('round-trips, including empty-looking, unicode and large strings', () => {
for (const plaintext of ['secret', ' ', 'p@ss wörd 🔑 日本語', 'x'.repeat(3_000_000)]) {
expect(decryptValue(key, encryptValue(key, plaintext))).toBe(plaintext);
}
});
it('uses the compact v1 format: prefix + base64(iv(12) || tag(16) || ciphertext)', () => {
const encrypted = encryptValue(key, 'hello');
expect(encrypted.startsWith('enc:insomnia:v1:')).toBe(true);
const payload = Buffer.from(encrypted.slice(ENCRYPTED_VALUE_PREFIX.length), 'base64');
expect(payload.length).toBe(12 + 16 + Buffer.byteLength('hello'));
// JSON-safe: only the base64 alphabet after the prefix
expect(encrypted.slice(ENCRYPTED_VALUE_PREFIX.length)).toMatch(/^[A-Za-z0-9+/]+={0,2}$/);
expect(JSON.stringify(encrypted)).toBe(`"${encrypted}"`);
});
it('uses a fresh random IV for every value', () => {
expect(encryptValue(key, 'same')).not.toBe(encryptValue(key, 'same'));
});
it('replaces a lone surrogate with U+FFFD (known, accepted gap)', () => {
expect(decryptValue(key, encryptValue(key, 'a\uD800b'))).toBe('a�b');
});
it('throws with the wrong key', () => {
const otherKey = generateKeySync('aes', { length: 256 });
expect(() => decryptValue(otherKey, encryptValue(key, 'secret'))).toThrow();
});
it('throws when the ciphertext, tag or IV is tampered with', () => {
const encrypted = encryptValue(key, 'secret value');
const payload = Buffer.from(encrypted.slice(ENCRYPTED_VALUE_PREFIX.length), 'base64');
for (const offset of [0, 12, 28]) {
const tampered = Buffer.from(payload);
tampered[offset] ^= 0x01;
expect(() => decryptValue(key, ENCRYPTED_VALUE_PREFIX + tampered.toString('base64'))).toThrow();
}
});
it('throws for values that are not well-formed ciphertext', () => {
expect(() => decryptValue(key, 'plain')).toThrow();
expect(() => decryptValue(key, `${ENCRYPTED_VALUE_PREFIX}${b64(27)}`)).toThrow();
});
});
describe('isWellFormedCiphertext()', () => {
it('accepts real ciphertext', () => {
expect(isWellFormedCiphertext(encryptValue(key, 'a'))).toBe(true);
expect(isWellFormedCiphertext(encryptValue(key, 'a'.repeat(1000)))).toBe(true);
});
it('requires the prefix', () => {
expect(isWellFormedCiphertext(b64(30))).toBe(false);
expect(isWellFormedCiphertext(`enc:${b64(30)}`)).toBe(false);
expect(isWellFormedCiphertext(`enc:insomnia:v2:${b64(30)}`)).toBe(false);
});
it('requires at least 28 decoded bytes (12-byte IV + 16-byte tag)', () => {
expect(isWellFormedCiphertext(`${ENCRYPTED_VALUE_PREFIX}${b64(28)}`)).toBe(true);
expect(isWellFormedCiphertext(`${ENCRYPTED_VALUE_PREFIX}${b64(27)}`)).toBe(false);
expect(isWellFormedCiphertext(`${ENCRYPTED_VALUE_PREFIX}${b64(1)}`)).toBe(false);
expect(isWellFormedCiphertext(ENCRYPTED_VALUE_PREFIX)).toBe(false);
});
it('requires valid standard base64: alphabet, length multiple of 4, correct padding', () => {
const valid = b64(30); // 40 chars, no padding
expect(valid.length % 4).toBe(0);
expect(isWellFormedCiphertext(`${ENCRYPTED_VALUE_PREFIX}${valid}`)).toBe(true);
// wrong length
expect(isWellFormedCiphertext(`${ENCRYPTED_VALUE_PREFIX}${valid}A`)).toBe(false);
expect(isWellFormedCiphertext(`${ENCRYPTED_VALUE_PREFIX}${valid.slice(0, -1)}`)).toBe(false);
// characters outside the alphabet (url-safe base64, whitespace, punctuation)
expect(isWellFormedCiphertext(`${ENCRYPTED_VALUE_PREFIX}${valid.slice(0, -4)}-_AA`)).toBe(false);
expect(isWellFormedCiphertext(`${ENCRYPTED_VALUE_PREFIX}${valid.slice(0, -4)} AAA`)).toBe(false);
expect(isWellFormedCiphertext(`${ENCRYPTED_VALUE_PREFIX}${valid}\n`)).toBe(false);
// padding only at the end, at most two
expect(isWellFormedCiphertext(`${ENCRYPTED_VALUE_PREFIX}${valid.slice(0, 8)}=${valid.slice(9)}`)).toBe(false);
expect(isWellFormedCiphertext(`${ENCRYPTED_VALUE_PREFIX}${valid.slice(0, -3)}===`)).toBe(false);
});
it('handles padded base64', () => {
// 29 bytes -> 40 chars with "=" padding (29 % 3 === 2)
const padded = b64(29);
expect(padded.endsWith('=')).toBe(true);
expect(isWellFormedCiphertext(`${ENCRYPTED_VALUE_PREFIX}${padded}`)).toBe(true);
// 28 bytes -> "==" or "=" depending on length; 28 % 3 === 1 -> "=="
expect(b64(28).endsWith('==')).toBe(true);
});
it('does not overflow the stack on multi-megabyte values', () => {
expect(isWellFormedCiphertext(`${ENCRYPTED_VALUE_PREFIX}${b64(6_000_000)}`)).toBe(true);
});
});
describe('assertFieldEncryptionKey()', () => {
it('accepts a 256-bit secret key', () => {
expect(() => assertFieldEncryptionKey(key)).not.toThrow();
});
it('rejects other key sizes', () => {
expect(() => assertFieldEncryptionKey(createSecretKey(randomBytes(16)))).toThrow();
expect(() => assertFieldEncryptionKey(createSecretKey(randomBytes(64)))).toThrow();
});
it('rejects non-keys', () => {
expect(() => assertFieldEncryptionKey(undefined as never)).toThrow();
expect(() => assertFieldEncryptionKey('key' as never)).toThrow();
});
});