project-env.ts140 lines · main
1import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto';
2
3import { newId, NotFoundError, ValidationError } from '@briven/shared';
4import { and, eq } from 'drizzle-orm';
5
6import { getDb } from '../db/client.js';
7import { projectEnvVars } from '../db/schema.js';
8import { env } from '../env.js';
9
10/**
11 * Per-project env vars. Values are encrypted at rest with AES-256-GCM. The
12 * KEK is `BRIVEN_ENCRYPTION_KEY` — rotating it means reading every ciphertext
13 * with the old key and re-writing with the new, handled by a Phase 3 rotate
14 * script (not yet).
15 *
16 * IV format: `<12-byte-iv><16-byte-tag><ciphertext>`, base64-encoded.
17 */
18
19const KEY_RE = /^[A-Z_][A-Z0-9_]{0,63}$/;
20
21function key(): Buffer {
22 const raw = env.BRIVEN_ENCRYPTION_KEY;
23 if (!raw) {
24 throw new ValidationError('BRIVEN_ENCRYPTION_KEY is not configured');
25 }
26 // Accept either a raw 32-byte hex string OR any-length secret (hash it).
27 if (/^[0-9a-f]{64}$/i.test(raw)) return Buffer.from(raw, 'hex');
28 return createHash('sha256').update(raw).digest();
29}
30
31export function encryptValue(plaintext: string): string {
32 const iv = randomBytes(12);
33 const cipher = createCipheriv('aes-256-gcm', key(), iv);
34 const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
35 const tag = cipher.getAuthTag();
36 return Buffer.concat([iv, tag, enc]).toString('base64');
37}
38
39export function decryptValue(stored: string): string {
40 const buf = Buffer.from(stored, 'base64');
41 const iv = buf.subarray(0, 12);
42 const tag = buf.subarray(12, 28);
43 const ciphertext = buf.subarray(28);
44 const decipher = createDecipheriv('aes-256-gcm', key(), iv);
45 decipher.setAuthTag(tag);
46 return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
47}
48
49export interface MaskedEnvVar {
50 id: string;
51 key: string;
52 lastFour: string;
53 createdAt: Date;
54 updatedAt: Date;
55}
56
57function mask(plaintext: string): string {
58 return plaintext.length <= 4 ? plaintext : plaintext.slice(-4);
59}
60
61export async function listEnvForProject(projectId: string): Promise<MaskedEnvVar[]> {
62 const db = getDb();
63 const rows = await db
64 .select()
65 .from(projectEnvVars)
66 .where(eq(projectEnvVars.projectId, projectId));
67 return rows.map((r) => ({
68 id: r.id,
69 key: r.key,
70 lastFour: mask(decryptValue(r.encryptedValue)),
71 createdAt: r.createdAt,
72 updatedAt: r.updatedAt,
73 }));
74}
75
76export async function getPlainEnvForProject(projectId: string): Promise<Record<string, string>> {
77 const db = getDb();
78 const rows = await db
79 .select()
80 .from(projectEnvVars)
81 .where(eq(projectEnvVars.projectId, projectId));
82 const out: Record<string, string> = {};
83 for (const r of rows) out[r.key] = decryptValue(r.encryptedValue);
84 return out;
85}
86
87export async function upsertEnvVar(input: {
88 projectId: string;
89 key: string;
90 value: string;
91 createdBy: string | null;
92}): Promise<void> {
93 if (!KEY_RE.test(input.key)) {
94 throw new ValidationError(
95 'env var key must be uppercase letters, digits and underscores, starting with a letter or underscore',
96 { key: input.key },
97 );
98 }
99 const db = getDb();
100 const encryptedValue = encryptValue(input.value);
101 await db
102 .insert(projectEnvVars)
103 .values({
104 id: newId('ev'),
105 projectId: input.projectId,
106 key: input.key,
107 encryptedValue,
108 createdBy: input.createdBy,
109 })
110 .onConflictDoUpdate({
111 target: [projectEnvVars.projectId, projectEnvVars.key],
112 set: { encryptedValue, updatedAt: new Date() },
113 });
114}
115
116export async function deleteEnvVar(projectId: string, envVarId: string): Promise<void> {
117 const db = getDb();
118 const [row] = await db
119 .select()
120 .from(projectEnvVars)
121 .where(and(eq(projectEnvVars.id, envVarId), eq(projectEnvVars.projectId, projectId)))
122 .limit(1);
123 if (!row) throw new NotFoundError('env_var', envVarId);
124 await db.delete(projectEnvVars).where(eq(projectEnvVars.id, envVarId));
125}
126
127export async function deleteEnvVarByKey(
128 projectId: string,
129 key: string,
130): Promise<{ id: string; key: string }> {
131 const db = getDb();
132 const [row] = await db
133 .select()
134 .from(projectEnvVars)
135 .where(and(eq(projectEnvVars.key, key), eq(projectEnvVars.projectId, projectId)))
136 .limit(1);
137 if (!row) throw new NotFoundError('env_var', key);
138 await db.delete(projectEnvVars).where(eq(projectEnvVars.id, row.id));
139 return { id: row.id, key: row.key };
140}