Files
dockhand-upstream/tests/encryption-core.test.ts

133 lines
5.2 KiB
TypeScript

/**
* Core AES-256-GCM encrypt/decrypt round-trip + fail-closed behaviour.
*
* The existing encryption tests cover key ROTATION and the reserved-prefix
* bypass; this file covers the crypto CORE (encrypt, decrypt, decryptStrict,
* isEncrypted, generateKey) which had ~0 direct coverage. Deterministic because
* we pin ENCRYPTION_KEY (base64, 32 bytes) and clear the in-memory key cache so
* no key file is read or written.
*/
import { describe, it, expect, beforeEach } from 'bun:test';
import { randomBytes } from 'node:crypto';
import {
encrypt,
decrypt,
decryptStrict,
isEncrypted,
generateKey,
clearKeyCache,
} from '../src/lib/server/encryption';
const KEY_A = randomBytes(32).toString('base64');
const KEY_B = randomBytes(32).toString('base64');
// Point the key loader at a scratch dir with no key file, and pin the key via
// env so getOrCreateKey() is deterministic. clearKeyCache() forces a re-read.
function useKey(key: string) {
process.env.DATA_DIR = `/tmp/enc-core-${process.pid}-${Math.random().toString(36).slice(2)}`;
process.env.ENCRYPTION_KEY = key;
clearKeyCache();
}
describe('encryption core: encrypt/decrypt round-trip', () => {
beforeEach(() => useKey(KEY_A));
it('round-trips a value through encrypt → decrypt', () => {
const ct = encrypt('hunter2');
expect(ct?.startsWith('enc:v1:')).toBe(true);
expect(decrypt(ct)).toBe('hunter2');
});
it('produces a DIFFERENT ciphertext each time (random IV) but the same plaintext back', () => {
const a = encrypt('same');
const b = encrypt('same');
expect(a).not.toBe(b); // random IV → distinct blobs
expect(decrypt(a)).toBe('same');
expect(decrypt(b)).toBe('same');
});
it('does not double-encrypt an already-encrypted value', () => {
const ct = encrypt('secret');
expect(encrypt(ct)).toBe(ct);
});
it('encrypts a plaintext that merely STARTS with the reserved prefix (no leak)', () => {
const tricky = encrypt('enc:v1:notreallyciphertext');
expect(tricky).not.toBe('enc:v1:notreallyciphertext'); // was encrypted, not passed through
expect(decrypt(tricky)).toBe('enc:v1:notreallyciphertext');
});
it('passes null / undefined / empty straight through', () => {
expect(encrypt(null)).toBeNull();
expect(encrypt(undefined)).toBeUndefined();
expect(encrypt('')).toBe('');
expect(decrypt(null)).toBeNull();
expect(decrypt('')).toBe('');
});
it('returns non-prefixed (legacy plaintext) values unchanged on decrypt', () => {
expect(decrypt('plain-legacy-secret')).toBe('plain-legacy-secret');
});
it('isEncrypted is true only for a genuine ciphertext blob', () => {
expect(isEncrypted(encrypt('x'))).toBe(true);
expect(isEncrypted('plain')).toBe(false);
expect(isEncrypted('enc:v1:tooshort')).toBe(false); // prefix but not a valid blob
expect(isEncrypted(null)).toBe(false);
});
it('generateKey returns a 32-byte base64 key', () => {
expect(Buffer.from(generateKey(), 'base64').length).toBe(32);
});
});
describe('encryption core: fail-closed on wrong key / corruption', () => {
it('decrypt() returns the raw value on a wrong-key failure (avoids data loss)', () => {
useKey(KEY_A);
const ct = encrypt('secret')!;
useKey(KEY_B); // rotate to a different key WITHOUT re-encrypting
expect(decrypt(ct)).toBe(ct); // can't decrypt → original blob back
});
it('decryptStrict() THROWS on a wrong-key failure (never forwards ciphertext as the secret)', () => {
useKey(KEY_A);
const ct = encrypt('secret')!;
useKey(KEY_B);
expect(() => decryptStrict(ct)).toThrow(/Failed to decrypt/);
});
it('decryptStrict() still passes genuine plaintext through unchanged', () => {
useKey(KEY_A);
expect(decryptStrict('legacy-plaintext')).toBe('legacy-plaintext');
});
// M1: the ENCRYPTION_KEY rotation loop must re-encrypt secret_providers.config
// with the new key. This reproduces exactly what migrateCredentials does to that
// column (decrypt with the old key, re-encrypt with the new) and proves the row
// is readable afterwards - the omission bug left it decryptable only under the
// gone old key. (The full loop imports better-sqlite3 which bun test can't load,
// so this exercises the crypto contract the loop relies on.)
it('re-encrypting a provider config on rotation keeps it decryptable under the new key', () => {
const providerConfig = JSON.stringify({ address: 'https://vault.example', token: 'hvs.SECRET' });
useKey(KEY_A);
const stored = encrypt(providerConfig)!; // as createSecretProvider stores it
expect(isEncrypted(stored)).toBe(true);
// --- rotation: decrypt under the OLD key, re-encrypt under the NEW key ---
const plain = decryptStrict(stored); // old key still cached at this point
useKey(KEY_B); // switch to the new key (as the rotation tx does)
const reEncrypted = encrypt(plain)!;
// Under the new key the re-encrypted config round-trips to the original JSON.
expect(decryptStrict(reEncrypted)).toBe(providerConfig);
// And the ORIGINAL blob is now undecryptable (proves it HAD to be re-encrypted).
expect(() => decryptStrict(stored)).toThrow(/Failed to decrypt/);
});
it('decrypt() returns the raw value on a too-short / corrupt blob', () => {
useKey(KEY_A);
expect(decrypt('enc:v1:AAAA')).toBe('enc:v1:AAAA'); // decodes but < iv+tag+1
});
});