Files
STCourses/packages/feature-ai/test/crypto.test.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 <[email protected]>
2026-10-30 20:49:04 +01:00

52 lines
2.7 KiB
TypeScript

import { expect, test } from "bun:test";
import { byokAad, createSecretBox, legacyV1Encrypt, mcpAad, SERVER_KEY_AAD } from "../src/crypto";
test("round trips and uses a fresh IV each time", async () => {
const box = createSecretBox("s3cret");
const a = await box.seal("sk-ant-123", "byok", byokAad("u1", "anthropic"));
const b = await box.seal("sk-ant-123", "byok", byokAad("u1", "anthropic"));
expect(a).not.toBe(b);
expect(a).not.toContain("sk-ant-123");
expect(a.startsWith("v2.")).toBe(true);
expect(await box.open(a, "byok", byokAad("u1", "anthropic"))).toBe("sk-ant-123");
expect(await box.open(b, "byok", byokAad("u1", "anthropic"))).toBe("sk-ant-123");
});
test("another secret cannot decrypt", async () => {
const token = await createSecretBox("one").seal("key", "byok", "a");
await expect(createSecretBox("two").open(token, "byok", "a")).rejects.toThrow();
});
test("a tampered token is rejected", async () => {
const box = createSecretBox("one");
const token = await box.seal("key", "byok", "a");
const parts = token.split(".");
const ct = Buffer.from(parts[2]!, "base64");
ct[0] = ct[0]! ^ 1;
await expect(box.open(`${parts[0]}.${parts[1]}.${ct.toString("base64")}`, "byok", "a")).rejects.toThrow();
await expect(box.open("garbage", "byok", "a")).rejects.toThrow();
});
test("a token opens only for its own purpose and row (domain separation and AAD)", async () => {
const box = createSecretBox("one");
const byok = await box.seal("sk-user", "byok", byokAad("u1", "anthropic"));
await expect(box.open(byok, "byok", byokAad("u2", "anthropic"))).rejects.toThrow(); // another user
await expect(box.open(byok, "byok", byokAad("u1", "openai"))).rejects.toThrow(); // another provider
await expect(box.open(byok, "server-api-key", SERVER_KEY_AAD)).rejects.toThrow(); // another purpose
await expect(box.open(byok, "mcp", byokAad("u1", "anthropic"))).rejects.toThrow(); // same AAD, other HKDF key
const env = await box.seal("{}", "mcp", mcpAad("fs", "env"));
await expect(box.open(env, "mcp", mcpAad("fs", "headers"))).rejects.toThrow();
await expect(box.open(env, "mcp", mcpAad("web", "env"))).rejects.toThrow();
await expect(box.open(env, "byok", mcpAad("fs", "env"))).rejects.toThrow();
});
test("v1 tokens open only through openLegacy, and v2 tokens never do", async () => {
const box = createSecretBox("one");
const v1 = await legacyV1Encrypt("one", "old-key");
expect(v1.startsWith("v1.")).toBe(true);
expect(await box.openLegacy(v1)).toBe("old-key");
await expect(box.open(v1, "byok", byokAad("u1", "anthropic"))).rejects.toThrow(/format/);
await expect(box.openLegacy(await box.seal("x", "byok", "a"))).rejects.toThrow(/format/);
await expect(createSecretBox("two").openLegacy(v1)).rejects.toThrow();
});