feat(crypto): boot-generated key + AES-256-GCM sealed values

This commit is contained in:
marcuspaico
2026-08-17 14:07:50 -07:00
parent 0f96d5e3b8
commit 3c796da649
2 changed files with 66 additions and 0 deletions

31
server/src/lib/crypto.ts Normal file
View File

@@ -0,0 +1,31 @@
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
export function loadOrCreateKey(dataDir: string): Buffer {
mkdirSync(dataDir, { recursive: true });
const path = join(dataDir, "secret.key");
if (!existsSync(path)) writeFileSync(path, randomBytes(32), { mode: 0o600 });
const key = readFileSync(path);
if (key.length !== 32) throw new Error(`secret.key must be 32 bytes, got ${key.length}`);
return key;
}
export function encrypt(key: Buffer, plaintext: string): string {
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", key, iv);
const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
return "enc:" + Buffer.concat([iv, cipher.getAuthTag(), ct]).toString("base64");
}
export function decrypt(key: Buffer, sealed: string): string {
if (!sealed.startsWith("enc:")) throw new Error("not an encrypted value");
const buf = Buffer.from(sealed.slice(4), "base64");
const decipher = createDecipheriv("aes-256-gcm", key, buf.subarray(0, 12));
decipher.setAuthTag(buf.subarray(12, 28));
return Buffer.concat([decipher.update(buf.subarray(28)), decipher.final()]).toString("utf8");
}
export function mask(secret: string): string {
return secret.length >= 5 ? "…" + secret.slice(-4) : "…";
}