Files
STCourses/packages/feature-ai/src/crypto.ts
T
Lucas WintherandClaude Opus 5.5 f2169a1235 fix(ai): bind sealed secrets to purpose and row; never drop unreadable secrets
Review fix round 1.
- Secret box v2 tokens: one HKDF key per purpose (byok, server-api-key,
  mcp) and AES-GCM AAD bound to the row (user id + provider; server id +
  field). MCP env and headers are sealed per field. open() refuses v1
  tokens; the data upgrade re-seals v1 keys, v1 plaintext MCP values and
  the draft secretCipher, and marks ai_secrets_v2 only when nothing
  legacy is left (retried until the previous secret is back).
- Unreadable secrets are reported (admin: unreadableSecrets with server
  and field; user: byok.keyUnreadable), logged at startup, and kept on
  save unless replaced or cleared (clearApiKey, clearSecrets). The admin
  and account pages show the warnings.
- allowedOrigins and provider resolution no longer open MCP secrets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 20:49:04 +01:00

83 lines
4.3 KiB
TypeScript

/**
* Secret box for secrets at rest (API keys, MCP env and headers): AES-GCM-256 with keys derived by HKDF-SHA-256
* from STC_SECRET.
*
* - `v2.<base64 iv>.<base64 ciphertext+tag>` (v1.1): each purpose has its own HKDF key (`info` =
* `stcourses/<purpose>/v2`), and the AES-GCM additional data binds the token to the row it belongs to (for
* example the user id and provider of a BYOK key, or the MCP server id and field). A token copied to another
* row or purpose does not open, so the server cannot be used to decrypt one secret as another.
* - `v1.<iv>.<ct>` (v1): one key for everything (info `aes-gcm-256/v1`), no additional data. Only the v1.1 data
* upgrade opens these, to re-seal them as v2 (`openLegacy`); `open` refuses them.
*/
export type SecretPurpose = "byok" | "server-api-key" | "mcp";
export interface SecretBox {
seal(plain: string, purpose: SecretPurpose, aad: string): Promise<string>;
open(token: string, purpose: SecretPurpose, aad: string): Promise<string>;
/** v1 tokens only; for the data upgrade. */
openLegacy(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): Uint8Array<ArrayBuffer> => new Uint8Array(Buffer.from(s, "base64"));
const SALT = enc.encode("stcourses/byok");
export const isLegacyToken = (token: string): boolean => token.startsWith("v1.");
function deriveKey(secret: string, info: string): Promise<CryptoKey> {
return (async () => {
const ikm = await crypto.subtle.importKey("raw", enc.encode(secret), "HKDF", false, ["deriveKey"]);
return crypto.subtle.deriveKey({ name: "HKDF", hash: "SHA-256", salt: SALT, info: enc.encode(info) }, ikm, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
})();
}
function parseToken(token: string, version: "v1" | "v2"): { iv: Uint8Array<ArrayBuffer>; ct: Uint8Array<ArrayBuffer> } {
const [v, iv, ct, extra] = token.split(".");
if (v !== version || !iv || !ct || extra !== undefined) throw new Error("Unrecognised secret format");
return { iv: unb64(iv), ct: unb64(ct) };
}
/** Additional data: purpose and row identity, length-prefixed so no two (purpose, aad) pairs encode alike. */
const additionalData = (purpose: SecretPurpose, aad: string) => enc.encode(`stc/${purpose.length}:${purpose}/${aad.length}:${aad}`);
export function createSecretBox(secret: string): SecretBox {
const keys = new Map<string, Promise<CryptoKey>>();
const key = (info: string) => {
let k = keys.get(info);
if (!k) keys.set(info, (k = deriveKey(secret, info)));
return k;
};
return {
async seal(plain, purpose, aad) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: additionalData(purpose, aad) }, await key(`stcourses/${purpose}/v2`), enc.encode(plain)));
return `v2.${b64(iv)}.${b64(ct)}`;
},
async open(token, purpose, aad) {
const { iv, ct } = parseToken(token, "v2");
const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv, additionalData: additionalData(purpose, aad) }, await key(`stcourses/${purpose}/v2`), ct);
return dec.decode(plain);
},
async openLegacy(token) {
const { iv, ct } = parseToken(token, "v1");
return dec.decode(await crypto.subtle.decrypt({ name: "AES-GCM", iv }, await key("aes-gcm-256/v1"), ct));
},
};
}
/** The v1 token format, exactly as v1 wrote it. Only upgrade tests use it, to build v1 data. */
export async function legacyV1Encrypt(secret: string, plain: string): Promise<string> {
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, await deriveKey(secret, "aes-gcm-256/v1"), enc.encode(plain)));
return `v1.${b64(iv)}.${b64(ct)}`;
}
/** AAD for a user's own key: user id and provider kind (the user_api_keys row). */
export const byokAad = (userId: string, provider: string) => `${userId}\n${provider}`;
/** AAD for the admin's provider key (one per server). */
export const SERVER_KEY_AAD = "server";
/** AAD for an MCP server's env or headers: server id and field. */
export const mcpAad = (serverId: string, field: "env" | "headers") => `${serverId}\n${field}`;