feat(ai): package scaffold and AES-GCM secret box
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c2a3464937
commit
e1938b5be8
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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[] = [];
|
||||
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user