From e1938b5be8a3f15bf1a78e6915dae42302c716c9 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 03:13:50 +0100 Subject: [PATCH] feat(ai): package scaffold and AES-GCM secret box Co-Authored-By: Claude Opus 5.5 --- packages/feature-ai/package.json | 3 +- packages/feature-ai/src/crypto.ts | 41 +++++++++++++++++++++++++ packages/feature-ai/src/index.ts | 4 +++ packages/feature-ai/test/crypto.test.ts | 27 ++++++++++++++++ 4 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 packages/feature-ai/src/crypto.ts create mode 100644 packages/feature-ai/src/index.ts create mode 100644 packages/feature-ai/test/crypto.test.ts diff --git a/packages/feature-ai/package.json b/packages/feature-ai/package.json index 689f090..0a7eb1e 100644 --- a/packages/feature-ai/package.json +++ b/packages/feature-ai/package.json @@ -3,5 +3,6 @@ "version": "0.0.0", "private": true, "type": "module", - "license": "AGPL-3.0-only" + "license": "AGPL-3.0-only", + "main": "src/index.ts" } diff --git a/packages/feature-ai/src/crypto.ts b/packages/feature-ai/src/crypto.ts new file mode 100644 index 0000000..10f6456 --- /dev/null +++ b/packages/feature-ai/src/crypto.ts @@ -0,0 +1,41 @@ +/** + * Secret box for API keys at rest: AES-GCM-256 with a key derived by HKDF-SHA-256 from STC_SECRET. + * Token format: `v1..`. + */ +export interface SecretBox { + encrypt(plain: string): Promise; + decrypt(token: string): Promise; +} + +const enc = new TextEncoder(); +const dec = new TextDecoder(); +const b64 = (u: Uint8Array) => Buffer.from(u).toString("base64"); +const unb64 = (s: string) => new Uint8Array(Buffer.from(s, "base64")); + +export function createSecretBox(secret: string): SecretBox { + let keyP: Promise | null = null; + const key = () => + (keyP ??= (async () => { + const ikm = await crypto.subtle.importKey("raw", enc.encode(secret), "HKDF", false, ["deriveKey"]); + return crypto.subtle.deriveKey( + { name: "HKDF", hash: "SHA-256", salt: enc.encode("stcourses/byok"), info: enc.encode("aes-gcm-256/v1") }, + ikm, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"], + ); + })()); + return { + async encrypt(plain) { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, await key(), enc.encode(plain))); + return `v1.${b64(iv)}.${b64(ct)}`; + }, + async decrypt(token) { + const [v, iv, ct] = token.split("."); + if (v !== "v1" || !iv || !ct) throw new Error("Unrecognised secret format"); + const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv: unb64(iv) }, await key(), unb64(ct)); + return dec.decode(plain); + }, + }; +} diff --git a/packages/feature-ai/src/index.ts b/packages/feature-ai/src/index.ts new file mode 100644 index 0000000..404b2e2 --- /dev/null +++ b/packages/feature-ai/src/index.ts @@ -0,0 +1,4 @@ +export { createSecretBox, type SecretBox } from "./crypto"; + +/** Migration folders for this package. Empty: the AI package uses the ai_* tables from 0001_init.sql. */ +export const migrationsDirs: string[] = []; diff --git a/packages/feature-ai/test/crypto.test.ts b/packages/feature-ai/test/crypto.test.ts new file mode 100644 index 0000000..7bea5b8 --- /dev/null +++ b/packages/feature-ai/test/crypto.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from "bun:test"; +import { createSecretBox } from "../src/crypto"; + +test("round trips and uses a fresh IV each time", async () => { + const box = createSecretBox("s3cret"); + const a = await box.encrypt("sk-ant-123"); + const b = await box.encrypt("sk-ant-123"); + expect(a).not.toBe(b); + expect(a).not.toContain("sk-ant-123"); + expect(a.startsWith("v1.")).toBe(true); + expect(await box.decrypt(a)).toBe("sk-ant-123"); + expect(await box.decrypt(b)).toBe("sk-ant-123"); +}); + +test("another secret cannot decrypt", async () => { + const token = await createSecretBox("one").encrypt("key"); + await expect(createSecretBox("two").decrypt(token)).rejects.toThrow(); +}); + +test("a tampered token is rejected", async () => { + const token = await createSecretBox("one").encrypt("key"); + const parts = token.split("."); + const ct = Buffer.from(parts[2]!, "base64"); + ct[0] = ct[0]! ^ 1; + await expect(createSecretBox("one").decrypt(`${parts[0]}.${parts[1]}.${ct.toString("base64")}`)).rejects.toThrow(); + await expect(createSecretBox("one").decrypt("garbage")).rejects.toThrow(); +});