From 3c796da649f4108f64ef27f75061f14a76cd0682 Mon Sep 17 00:00:00 2001 From: marcuspaico Date: Mon, 17 Aug 2026 14:07:50 -0700 Subject: [PATCH] feat(crypto): boot-generated key + AES-256-GCM sealed values --- server/src/lib/crypto.ts | 31 +++++++++++++++++++++++++++++++ server/test/crypto.test.ts | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 server/src/lib/crypto.ts create mode 100644 server/test/crypto.test.ts diff --git a/server/src/lib/crypto.ts b/server/src/lib/crypto.ts new file mode 100644 index 0000000..46fa137 --- /dev/null +++ b/server/src/lib/crypto.ts @@ -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) : "…"; +} diff --git a/server/test/crypto.test.ts b/server/test/crypto.test.ts new file mode 100644 index 0000000..4873feb --- /dev/null +++ b/server/test/crypto.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { decrypt, encrypt, loadOrCreateKey, mask } from "../src/lib/crypto"; + +describe("crypto", () => { + test("key is created once and reused", () => { + const dir = mkdtempSync(join(tmpdir(), "helios-")); + const a = loadOrCreateKey(dir); + const b = loadOrCreateKey(dir); + expect(a.length).toBe(32); + expect(a.equals(b)).toBe(true); + }); + + test("encrypt/decrypt round-trip; ciphertexts differ per call", () => { + const key = loadOrCreateKey(mkdtempSync(join(tmpdir(), "helios-"))); + const sealed = encrypt(key, "sk-or-v1-secret"); + expect(sealed.startsWith("enc:")).toBe(true); + expect(decrypt(key, sealed)).toBe("sk-or-v1-secret"); + expect(encrypt(key, "sk-or-v1-secret")).not.toBe(sealed); + }); + + test("tampering fails closed", () => { + const key = loadOrCreateKey(mkdtempSync(join(tmpdir(), "helios-"))); + const sealed = encrypt(key, "x"); + const bad = sealed.slice(0, -2) + (sealed.endsWith("A") ? "BB" : "AA"); + expect(() => decrypt(key, bad)).toThrow(); + }); + + test("mask keeps only tail", () => { + expect(mask("sk-or-v1-abcdef")).toBe("…cdef"); + expect(mask("abc")).toBe("…"); + }); +});