Infrastructure build to support third-party app integrations

This commit is contained in:
KS Jannette
2026-08-01 07:35:01 -04:00
parent b4666c5439
commit 15af3465e2
53 changed files with 5864 additions and 659 deletions

View 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);
});
});