import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { randomBytes } from 'node:crypto'; import { encryptSecret, decryptSecret, hashApiKey, safeEquals, } from '../lib/crypto.js'; const base64Key = (bytes: number): string => randomBytes(bytes).toString('base64'); const VALID_KEY = base64Key(32); /** Rewrites one `iv:tag:payload` segment, flipping every bit of its first byte. */ const corruptSegment = (ciphertext: string, index: number): string => { const parts = ciphertext.split(':'); const bytes = Buffer.from(parts[index], 'base64'); bytes[0] = bytes[0] ^ 0xff; parts[index] = bytes.toString('base64'); return parts.join(':'); }; describe('encryptSecret / decryptSecret', () => { const originalKey = process.env.TOKEN_ENCRYPTION_KEY; beforeEach(() => { process.env.TOKEN_ENCRYPTION_KEY = VALID_KEY; }); afterEach(() => { if (originalKey === undefined) { delete process.env.TOKEN_ENCRYPTION_KEY; } else { process.env.TOKEN_ENCRYPTION_KEY = originalKey; } }); it('should round-trip a plaintext secret', () => { const plaintext = 'lin_oauth_abc123'; expect(decryptSecret(encryptSecret(plaintext))).toBe(plaintext); }); it('should round-trip an empty string and multi-byte characters', () => { expect(decryptSecret(encryptSecret(''))).toBe(''); expect(decryptSecret(encryptSecret('clé—😀'))).toBe('clé—😀'); }); it('should emit three base64 segments', () => { const parts = encryptSecret('token').split(':'); expect(parts).toHaveLength(3); expect(Buffer.from(parts[0], 'base64')).toHaveLength(12); expect(Buffer.from(parts[1], 'base64')).toHaveLength(16); }); it('should produce different ciphertext for the same plaintext each time', () => { const first = encryptSecret('same-secret'); const second = encryptSecret('same-secret'); expect(first).not.toBe(second); expect(first.split(':')[0]).not.toBe(second.split(':')[0]); expect(decryptSecret(first)).toBe('same-secret'); expect(decryptSecret(second)).toBe('same-secret'); }); it('should throw when the ciphertext payload is tampered with', () => { const tampered = corruptSegment(encryptSecret('payload-under-attack'), 2); expect(() => decryptSecret(tampered)).toThrow(); }); it('should throw when the auth tag is tampered with', () => { const tampered = corruptSegment(encryptSecret('tag-under-attack'), 1); expect(() => decryptSecret(tampered)).toThrow(); }); it('should throw when the iv is tampered with', () => { const tampered = corruptSegment(encryptSecret('iv-under-attack'), 0); expect(() => decryptSecret(tampered)).toThrow(); }); it('should throw for a ciphertext that is not in iv:tag:payload form', () => { expect(() => decryptSecret('not-a-ciphertext')).toThrow( /iv:tag:payload/ ); expect(() => decryptSecret('only:two')).toThrow(/iv:tag:payload/); expect(() => decryptSecret('a:b:c:d')).toThrow(/iv:tag:payload/); expect(() => decryptSecret('')).toThrow(/iv:tag:payload/); }); it('should throw when decrypting under a different key', () => { const ciphertext = encryptSecret('bound-to-one-key'); process.env.TOKEN_ENCRYPTION_KEY = base64Key(32); expect(() => decryptSecret(ciphertext)).toThrow(); }); it('should throw when TOKEN_ENCRYPTION_KEY is missing', () => { delete process.env.TOKEN_ENCRYPTION_KEY; expect(() => encryptSecret('anything')).toThrow(/not set/); expect(() => decryptSecret('a:b:c')).toThrow(/not set/); }); it('should throw when TOKEN_ENCRYPTION_KEY is empty', () => { process.env.TOKEN_ENCRYPTION_KEY = ''; expect(() => encryptSecret('anything')).toThrow(/not set/); }); it('should throw when the key decodes to the wrong byte length', () => { process.env.TOKEN_ENCRYPTION_KEY = base64Key(16); expect(() => encryptSecret('anything')).toThrow(/32 bytes, got 16/); process.env.TOKEN_ENCRYPTION_KEY = base64Key(48); expect(() => encryptSecret('anything')).toThrow(/32 bytes, got 48/); }); it('should read the key at call time rather than at import time', () => { process.env.TOKEN_ENCRYPTION_KEY = base64Key(31); expect(() => encryptSecret('anything')).toThrow(/got 31/); process.env.TOKEN_ENCRYPTION_KEY = VALID_KEY; expect(decryptSecret(encryptSecret('recovered'))).toBe('recovered'); }); }); describe('hashApiKey', () => { it('should be stable for the same input', () => { expect(hashApiKey('kg_live_abc')).toBe(hashApiKey('kg_live_abc')); }); it('should differ for different inputs', () => { expect(hashApiKey('kg_live_abc')).not.toBe(hashApiKey('kg_live_abd')); expect(hashApiKey('')).not.toBe(hashApiKey(' ')); }); it('should return a 64-character lowercase hex digest', () => { expect(hashApiKey('kg_live_abc')).toMatch(/^[0-9a-f]{64}$/); }); it('should match the plain sha256 digest of the input', () => { expect(hashApiKey('')).toBe( 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' ); }); }); describe('safeEquals', () => { it('should return true for identical strings', () => { expect(safeEquals('kg_live_abc', 'kg_live_abc')).toBe(true); expect(safeEquals('', '')).toBe(true); }); it('should return false for different strings of equal length', () => { expect(safeEquals('kg_live_abc', 'kg_live_abd')).toBe(false); }); it('should return false for strings of differing lengths without throwing', () => { expect(safeEquals('short', 'a-much-longer-value')).toBe(false); expect(safeEquals('', 'x')).toBe(false); }); it('should be case sensitive', () => { expect(safeEquals('Secret', 'secret')).toBe(false); }); });