Infrastructure build to support third-party app integrations
This commit is contained in:
170
backend/tests/crypto.test.ts
Normal file
170
backend/tests/crypto.test.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user