tenant-secret-store.ts120 lines · main
1import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from 'node:crypto';
2
3import { ValidationError } from '@briven/shared';
4
5// why: master keys are read directly from process.env (not the cached
6// `env` object) so a master-key rotation can take effect without an api
7// process restart. Same regex validation that env.ts applies at boot.
8
9/**
10 * Per-tenant encrypted secret store — Layer 2 primitive shared between
11 * briven auth and (future) briven pay.
12 *
13 * The wire format mirrors `services/project-env.ts` so anyone familiar with
14 * the existing project-env encryption finds no surprises:
15 *
16 * base64( <12-byte-iv> || <16-byte-gcm-tag> || <ciphertext> )
17 *
18 * What differs from project-env: the key is derived **per tenant** via
19 * HKDF-SHA256, so a leak of one tenant's plaintext does not let an attacker
20 * decrypt another tenant's blob. The `info=` byte string is service-scoped
21 * (`briven-auth-v1` vs `briven-pay-v1`) so a leak of one service's master
22 * key cannot decrypt the other service's secrets even if they share a
23 * tenant id.
24 *
25 * Architecture contract: see `ARCHITECTURE.md` §4. This file is one of the
26 * reuse points briven pay locks in for v1; do not change the key-derivation
27 * inputs without a master-key-rotation migration plan.
28 */
29
30const SERVICE_INFO = {
31 auth: Buffer.from('briven-auth-v1', 'utf8'),
32 pay: Buffer.from('briven-pay-v1', 'utf8'),
33} as const;
34export type TenantService = keyof typeof SERVICE_INFO;
35
36/**
37 * Resolve the master key for a service. Returns 32 raw bytes. Throws a
38 * `ValidationError` when the env var is missing or malformed — boot does
39 * not fail, but the first request to a tenant for that service does. This
40 * matches the existing `BRIVEN_ENCRYPTION_KEY` pattern in project-env.ts.
41 */
42function masterKey(service: TenantService): Buffer {
43 const envVar = service === 'auth' ? 'BRIVEN_AUTH_MASTER_KEY' : 'BRIVEN_PAY_MASTER_KEY';
44 const raw = process.env[envVar];
45 if (!raw) {
46 throw new ValidationError(`master key not configured for service: ${service}`);
47 }
48 if (!/^[0-9a-f]{64}$/i.test(raw)) {
49 throw new ValidationError(
50 `master key for ${service} must be 32 random bytes encoded as 64 hex characters`,
51 );
52 }
53 return Buffer.from(raw, 'hex');
54}
55
56/**
57 * Derive a per-tenant key. HKDF inputs:
58 * IKM = service master key (32 bytes)
59 * salt = utf8 bytes of the project id (e.g. 'p_01HX...')
60 * info = service-scoped string ('briven-auth-v1' or 'briven-pay-v1')
61 *
62 * Output: 32 bytes suitable for AES-256-GCM.
63 */
64function tenantKey(service: TenantService, projectId: string): Buffer {
65 if (!projectId || projectId.length === 0) {
66 throw new ValidationError('projectId is required to derive a tenant key');
67 }
68 const ikm = masterKey(service);
69 const salt = Buffer.from(projectId, 'utf8');
70 const info = SERVICE_INFO[service];
71 // hkdfSync returns ArrayBuffer; wrap as Buffer for the crypto APIs below.
72 return Buffer.from(hkdfSync('sha256', ikm, salt, info, 32));
73}
74
75export interface EncryptArgs {
76 service: TenantService;
77 projectId: string;
78 plaintext: string;
79}
80
81export interface DecryptArgs {
82 service: TenantService;
83 projectId: string;
84 ciphertext: string;
85}
86
87export function encryptTenantSecret({ service, projectId, plaintext }: EncryptArgs): string {
88 const k = tenantKey(service, projectId);
89 const iv = randomBytes(12);
90 const cipher = createCipheriv('aes-256-gcm', k, iv);
91 const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
92 const tag = cipher.getAuthTag();
93 return Buffer.concat([iv, tag, enc]).toString('base64');
94}
95
96export function decryptTenantSecret({ service, projectId, ciphertext }: DecryptArgs): string {
97 const k = tenantKey(service, projectId);
98 const buf = Buffer.from(ciphertext, 'base64');
99 if (buf.length < 12 + 16 + 1) {
100 throw new ValidationError('ciphertext too short — expected at least 29 bytes');
101 }
102 const iv = buf.subarray(0, 12);
103 const tag = buf.subarray(12, 28);
104 const body = buf.subarray(28);
105 const decipher = createDecipheriv('aes-256-gcm', k, iv);
106 decipher.setAuthTag(tag);
107 return Buffer.concat([decipher.update(body), decipher.final()]).toString('utf8');
108}
109
110/**
111 * Testing helper — visible for unit tests only. Not part of the public API.
112 * The derived key is sensitive; do not log it. Returning it lets the test
113 * harness assert isolation properties without re-deriving the key inline.
114 */
115export function __unsafe_tenantKey_forTesting(
116 service: TenantService,
117 projectId: string,
118): Buffer {
119 return tenantKey(service, projectId);
120}