tenant-secret-store.test.ts128 lines · main
| 1 | import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; |
| 2 | import { randomBytes } from 'node:crypto'; |
| 3 | |
| 4 | import { 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. |
| 8 | const AUTH_KEY = randomBytes(32).toString('hex'); |
| 9 | const PAY_KEY = randomBytes(32).toString('hex'); |
| 10 | |
| 11 | const originalAuth = process.env.BRIVEN_AUTH_MASTER_KEY; |
| 12 | const originalPay = process.env.BRIVEN_PAY_MASTER_KEY; |
| 13 | |
| 14 | beforeAll(() => { |
| 15 | process.env.BRIVEN_AUTH_MASTER_KEY = AUTH_KEY; |
| 16 | process.env.BRIVEN_PAY_MASTER_KEY = PAY_KEY; |
| 17 | }); |
| 18 | |
| 19 | afterAll(() => { |
| 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. |
| 27 | async function loadStore() { |
| 28 | return import('./tenant-secret-store.js'); |
| 29 | } |
| 30 | |
| 31 | describe('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 | }); |