tenant-secret-store.ts120 lines · main
| 1 | import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from 'node:crypto'; |
| 2 | |
| 3 | import { 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 | |
| 30 | const SERVICE_INFO = { |
| 31 | auth: Buffer.from('briven-auth-v1', 'utf8'), |
| 32 | pay: Buffer.from('briven-pay-v1', 'utf8'), |
| 33 | } as const; |
| 34 | export 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 | */ |
| 42 | function 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 | */ |
| 64 | function 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 | |
| 75 | export interface EncryptArgs { |
| 76 | service: TenantService; |
| 77 | projectId: string; |
| 78 | plaintext: string; |
| 79 | } |
| 80 | |
| 81 | export interface DecryptArgs { |
| 82 | service: TenantService; |
| 83 | projectId: string; |
| 84 | ciphertext: string; |
| 85 | } |
| 86 | |
| 87 | export 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 | |
| 96 | export 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 | */ |
| 115 | export function __unsafe_tenantKey_forTesting( |
| 116 | service: TenantService, |
| 117 | projectId: string, |
| 118 | ): Buffer { |
| 119 | return tenantKey(service, projectId); |
| 120 | } |