mirror of
https://github.com/Kong/insomnia.git
synced 2026-11-04 03:55:19 -05:00
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>
126 lines
5.5 KiB
TypeScript
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();
|
|
});
|
|
});
|