feat(ai): package scaffold and AES-GCM secret box

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 03:13:50 +01:00
co-authored by Claude Opus 5.5
parent c2a3464937
commit e1938b5be8
4 changed files with 74 additions and 1 deletions
+2 -1
View File
@@ -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"
}
+41
View File
@@ -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.<base64 iv>.<base64 ciphertext+tag>`.
*/
export interface SecretBox {
encrypt(plain: string): Promise<string>;
decrypt(token: string): Promise<string>;
}
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<CryptoKey> | 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);
},
};
}
+4
View File
@@ -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[] = [];
+27
View File
@@ -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();
});