tenant-secret-store.test.ts128 lines · main
1import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
2import { randomBytes } from 'node:crypto';
3
4import { ValidationError } from '@briven/shared';
5
6// Master keys must be present before the module reads them. Env is loaded
7// once at import time via zod, so we set the values *before* importing.
8const AUTH_KEY = randomBytes(32).toString('hex');
9const PAY_KEY = randomBytes(32).toString('hex');
10
11const originalAuth = process.env.BRIVEN_AUTH_MASTER_KEY;
12const originalPay = process.env.BRIVEN_PAY_MASTER_KEY;
13
14beforeAll(() => {
15 process.env.BRIVEN_AUTH_MASTER_KEY = AUTH_KEY;
16 process.env.BRIVEN_PAY_MASTER_KEY = PAY_KEY;
17});
18
19afterAll(() => {
20 if (originalAuth === undefined) delete process.env.BRIVEN_AUTH_MASTER_KEY;
21 else process.env.BRIVEN_AUTH_MASTER_KEY = originalAuth;
22 if (originalPay === undefined) delete process.env.BRIVEN_PAY_MASTER_KEY;
23 else process.env.BRIVEN_PAY_MASTER_KEY = originalPay;
24});
25
26// Dynamic import so the env-keyed initialiser runs after we set the keys.
27async function loadStore() {
28 return import('./tenant-secret-store.js');
29}
30
31describe('tenant-secret-store — Layer 2 primitive (ARCHITECTURE.md §4)', () => {
32 test('encrypt then decrypt roundtrips for the same service + projectId', async () => {
33 const { encryptTenantSecret, decryptTenantSecret } = await loadStore();
34 const plaintext = 'gho_supersecret_oauth_client_secret_123';
35 const blob = encryptTenantSecret({
36 service: 'auth',
37 projectId: 'p_alpha',
38 plaintext,
39 });
40 expect(typeof blob).toBe('string');
41 expect(blob.length).toBeGreaterThan(0);
42 const out = decryptTenantSecret({
43 service: 'auth',
44 projectId: 'p_alpha',
45 ciphertext: blob,
46 });
47 expect(out).toBe(plaintext);
48 });
49
50 test('different projectIds produce different ciphertexts for the same plaintext', async () => {
51 const { encryptTenantSecret } = await loadStore();
52 const a = encryptTenantSecret({ service: 'auth', projectId: 'p_alpha', plaintext: 'x' });
53 const b = encryptTenantSecret({ service: 'auth', projectId: 'p_bravo', plaintext: 'x' });
54 expect(a).not.toBe(b);
55 });
56
57 test('cross-tenant decrypt fails — tenant A cannot read tenant B (forward isolation)', async () => {
58 const { encryptTenantSecret, decryptTenantSecret } = await loadStore();
59 const blob = encryptTenantSecret({
60 service: 'auth',
61 projectId: 'p_alpha',
62 plaintext: 'tenant-A-only',
63 });
64 expect(() =>
65 decryptTenantSecret({ service: 'auth', projectId: 'p_bravo', ciphertext: blob }),
66 ).toThrow(); // GCM tag mismatch
67 });
68
69 test('cross-service decrypt fails — auth secret unreadable by pay key (service isolation)', async () => {
70 const { encryptTenantSecret, decryptTenantSecret } = await loadStore();
71 const blob = encryptTenantSecret({
72 service: 'auth',
73 projectId: 'p_alpha',
74 plaintext: 'auth-side-only',
75 });
76 expect(() =>
77 decryptTenantSecret({ service: 'pay', projectId: 'p_alpha', ciphertext: blob }),
78 ).toThrow();
79 });
80
81 test('tampered ciphertext is rejected (AES-GCM auth tag verification)', async () => {
82 const { encryptTenantSecret, decryptTenantSecret } = await loadStore();
83 const blob = encryptTenantSecret({
84 service: 'auth',
85 projectId: 'p_alpha',
86 plaintext: 'integrity-matters',
87 });
88 // Flip a single byte in the body (after iv+tag).
89 const buf = Buffer.from(blob, 'base64');
90 buf[buf.length - 1] = buf[buf.length - 1]! ^ 0xff;
91 const tampered = buf.toString('base64');
92 expect(() =>
93 decryptTenantSecret({ service: 'auth', projectId: 'p_alpha', ciphertext: tampered }),
94 ).toThrow();
95 });
96
97 test('truncated ciphertext returns ValidationError (length sanity)', async () => {
98 const { decryptTenantSecret } = await loadStore();
99 expect(() =>
100 decryptTenantSecret({
101 service: 'auth',
102 projectId: 'p_alpha',
103 ciphertext: Buffer.from('toosmall').toString('base64'),
104 }),
105 ).toThrow(ValidationError);
106 });
107
108 test('empty projectId rejected — no tenant key without a tenant', async () => {
109 const { encryptTenantSecret } = await loadStore();
110 expect(() =>
111 encryptTenantSecret({ service: 'auth', projectId: '', plaintext: 'x' }),
112 ).toThrow(ValidationError);
113 });
114
115 test('different services with the same projectId derive different keys', async () => {
116 const { __unsafe_tenantKey_forTesting } = await loadStore();
117 const k1 = __unsafe_tenantKey_forTesting('auth', 'p_alpha');
118 const k2 = __unsafe_tenantKey_forTesting('pay', 'p_alpha');
119 expect(k1.equals(k2)).toBe(false);
120 });
121
122 test('same service + same projectId is deterministic across calls', async () => {
123 const { __unsafe_tenantKey_forTesting } = await loadStore();
124 const k1 = __unsafe_tenantKey_forTesting('auth', 'p_alpha');
125 const k2 = __unsafe_tenantKey_forTesting('auth', 'p_alpha');
126 expect(k1.equals(k2)).toBe(true);
127 });
128});