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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-30 03:13:50 +01:00
1 parent c2a3464937
commit e1938b5be8
4 files changed
+74 -1

No files matched your search

+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[] = [];