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]>
52 lines
2.7 KiB
TypeScript
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();
|
|
});
|