feat(ai): encrypt MCP env and headers at rest and mask them in the admin API

env/headers are sealed into secretCipher with the BYOK AES-GCM/HKDF box.
The admin GET returns masked values; a mask posted back for the same
server and key keeps the stored value, any other mask is a 400. The chat
and the test button use the stored values. upgradeAiData seals plaintext
v1 rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-30 20:33:56 +01:00
1 parent a995900fba
commit ddc66f5be5
8 files changed
+261 -12

No files matched your search

+38
View File
@@ -245,3 +245,41 @@ forEachDb("authoring uses core canView: the global author role reaches restricte
await db.sql`INSERT INTO course_acl (course_id, principal_type, principal_id) VALUES ('secret', 'user', 'ann')`;
expect((await call("POST", "/api/ai/studio/generate", "ann", body)).status).toBe(403);
});
forEachDb("the admin API masks MCP secrets, and the test button and chat use the stored values", async (db) => {
const seen: (string | null)[] = [];
const mcpFetch = (async (_url: string | URL | Request, init?: RequestInit) => {
seen.push(new Headers(init?.headers).get("authorization"));
const msg = JSON.parse(String(init?.body ?? "{}"));
if (msg.id === undefined) return new Response(null, { status: 202 });
const result = msg.method === "initialize" ? { protocolVersion: "2025-06-18", capabilities: {}, serverInfo: { name: "f", version: "1" } }
: msg.method === "tools/list" ? { tools: [{ name: "ping", description: "Ping", inputSchema: { type: "object" } }] }
: {};
return Response.json({ jsonrpc: "2.0", id: msg.id, result });
}) as unknown as typeof fetch;
const { call } = await setup(db, [() => ({ text: "ok" })], { fetch: mcpFetch });
const web = { id: "web", name: "Web", transport: "http", url: "https://mcp.example.com/mcp", headers: { authorization: "Bearer hdr-SECRET-abcdef" } };
expect((await call("PUT", "/api/ai/admin/config", "owner", { provider: { kind: "anthropic", apiKey: "k" }, mcpServers: [web] })).status).toBe(200);
const text = await (await call("GET", "/api/ai/admin/config", "owner")).text();
expect(text).not.toContain("SECRET");
const shown = JSON.parse(text).mcpServers[0];
expect(shown.headers).toEqual({ authorization: "••••cdef" });
// Saving the page unchanged keeps the secret; a stale mask is a 400 and changes nothing.
expect((await call("PUT", "/api/ai/admin/config", "owner", { provider: { kind: "anthropic" }, mcpServers: [shown] })).status).toBe(200);
const stale = await call("PUT", "/api/ai/admin/config", "owner", { provider: { kind: "anthropic" }, mcpServers: [{ ...shown, id: "web2" }] });
expect(stale.status).toBe(400);
expect((await stale.json()).error).toContain("Re-enter");
// The test button posts the masked draft; the MCP server receives the stored header.
const t = await (await call("POST", "/api/ai/admin/mcp/test", "owner", shown)).json();
expect(t).toMatchObject({ ok: true, tools: [{ name: "ping" }] });
expect(seen.filter(Boolean).every((h) => h === "Bearer hdr-SECRET-abcdef")).toBe(true);
expect(seen.length).toBeGreaterThan(0);
// Chat connects with the stored header too.
seen.length = 0;
await (await call("POST", "/api/ai/courses/sample/chat", "ann", { message: "hello" })).text();
expect(seen.length).toBeGreaterThan(0);
expect(seen.filter(Boolean).every((h) => h === "Bearer hdr-SECRET-abcdef")).toBe(true);
});
+47 -1
View File
@@ -1,7 +1,7 @@
import { expect } from "bun:test";
import { forEachAiDb as forEachDb } from "./helpers";
import { createSecretBox } from "../src/crypto";
import { aiSettingsRepo } from "../src/settings";
import { aiSettingsRepo, maskSecret, MaskedSecretError } from "../src/settings";
const box = createSecretBox("test-secret");
@@ -75,3 +75,49 @@ forEachDb("mentor toggles are rows in ai_mentor_prefs, not config_json", async (
await r.setMentorOff("u1", "sample", false);
expect(await db.sql`SELECT user_id FROM ai_mentor_prefs`).toHaveLength(0);
});
forEachDb("MCP env and headers are encrypted, masked when read and kept when the mask is sent back", async (db) => {
const r = aiSettingsRepo(db, box);
await r.saveServer({ provider: null, mcpServers: [
{ id: "fs", name: "Files", transport: "stdio", command: "mcp-fs", env: { FS_TOKEN: "tok-SECRET-123456", SHORT: "abc" } },
{ id: "web", name: "Web", transport: "http", url: "https://mcp.example.com/mcp", headers: { authorization: "Bearer hdr-SECRET-abcdef" } },
] });
const raw = (await db.sql`SELECT config_json FROM ai_settings WHERE scope = 'server'`)[0].config_json;
expect(raw).not.toContain("SECRET");
expect(raw).not.toContain("abc\"");
const shown = await r.getServer();
expect(JSON.stringify(shown)).not.toContain("SECRET");
expect(shown.mcpServers[0]!.env).toEqual({ FS_TOKEN: "••••3456", SHORT: "••••••••" });
expect(shown.mcpServers[1]!.headers).toEqual({ authorization: "••••cdef" });
expect(maskSecret("abc")).toBe("••••••••");
// The admin page sends the masks back unchanged (plus a new variable): stored values are kept.
await r.saveServer({ provider: null, mcpServers: [
{ ...shown.mcpServers[0]!, env: { ...shown.mcpServers[0]!.env, NEW: "plain-new-value" } },
shown.mcpServers[1]!,
] });
const full = await r.mcpServersWithSecrets();
expect(full[0]!.env).toEqual({ FS_TOKEN: "tok-SECRET-123456", SHORT: "abc", NEW: "plain-new-value" });
expect(full[1]!.headers).toEqual({ authorization: "Bearer hdr-SECRET-abcdef" });
// A new value replaces the old one; a removed key is gone.
await r.saveServer({ provider: null, mcpServers: [{ id: "fs", name: "Files", transport: "stdio", command: "mcp-fs", env: { FS_TOKEN: "tok-ROTATED-999999" } }] });
expect((await r.mcpServersWithSecrets())).toEqual([{ id: "fs", name: "Files", transport: "stdio", command: "mcp-fs", env: { FS_TOKEN: "tok-ROTATED-999999" } }]);
// A mask that does not match the stored value (other server id, or a stale mask) is refused, never stored.
await expect(r.saveServer({ provider: null, mcpServers: [{ id: "fs2", name: "Files", transport: "stdio", command: "mcp-fs", env: { FS_TOKEN: "••••9999" } }] })).rejects.toThrow(MaskedSecretError);
await expect(r.saveServer({ provider: null, mcpServers: [{ id: "fs", name: "Files", transport: "stdio", command: "mcp-fs", env: { FS_TOKEN: "••••0000" } }] })).rejects.toThrow(/Re-enter/);
expect((await r.mcpServersWithSecrets())[0]!.env).toEqual({ FS_TOKEN: "tok-ROTATED-999999" });
// resolveMcpMasks: the admin "test" button sends a masked draft; the server fills in the stored values.
expect(await r.resolveMcpMasks({ id: "fs", name: "Files", transport: "stdio", command: "mcp-fs", env: { FS_TOKEN: "••••9999" } }))
.toEqual({ id: "fs", name: "Files", transport: "stdio", command: "mcp-fs", env: { FS_TOKEN: "tok-ROTATED-999999" } });
});
forEachDb("MCP secrets sealed with another STC_SECRET are dropped, not crashed on", async (db) => {
await aiSettingsRepo(db, createSecretBox("old-secret")).saveServer({ provider: null, mcpServers: [{ id: "fs", name: "Files", transport: "stdio", command: "x", env: { T: "tok-SECRET-123456" } }] });
const r = aiSettingsRepo(db, box);
expect((await r.getServer()).mcpServers).toEqual([{ id: "fs", name: "Files", transport: "stdio", command: "x" }]);
expect(await r.mcpServersWithSecrets()).toEqual([{ id: "fs", name: "Files", transport: "stdio", command: "x" }]);
});
+32
View File
@@ -82,3 +82,35 @@ forEachV1Db("v1 upgrade: the settings repo upgrades on first use", async (db) =>
expect(await aiSettingsRepo(db, box).isMentorOff("u1", "sample")).toBe(true);
expect(JSON.parse((await db.sql`SELECT config_json FROM ai_settings WHERE owner_id = 'u1'`)[0].config_json)).toEqual({ byok: null });
});
forEachV1Db("v1 upgrade: plaintext MCP env and headers are encrypted at rest", async (db) => {
const apiKeyCipher = await box.encrypt("sk-ant-SERVER");
await seedSettings(db, "server", "", {
provider: { kind: "anthropic" }, apiKeyCipher, allowedBaseUrls: ["http://ollama:11434"],
mcpServers: [
{ id: "fs", name: "Files", transport: "stdio", command: "mcp-fs", args: ["--root", "/x"], env: { FS_TOKEN: "tok-PLAIN-123456" } },
{ id: "web", name: "Web", transport: "http", url: "https://mcp.example.com/mcp", headers: { authorization: "Bearer hdr-PLAIN-abcdef" }, courseIds: ["sample"] },
{ id: "bare", name: "Bare", transport: "http", url: "https://bare.example.com/mcp" },
],
});
await migrate(db, aiMigrations);
expect(await upgradeAiData(db, box)).toBe(true);
const raw = (await db.sql`SELECT config_json FROM ai_settings WHERE scope = 'server'`)[0].config_json as string;
expect(raw).not.toContain("PLAIN");
const stored = JSON.parse(raw);
expect(stored.apiKeyCipher).toBe(apiKeyCipher);
expect(stored.allowedBaseUrls).toEqual(["http://ollama:11434"]);
expect(stored.mcpServers.map((s: { id: string; env?: unknown; headers?: unknown; secretCipher?: string }) => [s.id, !!s.env, !!s.headers, !!s.secretCipher]))
.toEqual([["fs", false, false, true], ["web", false, false, true], ["bare", false, false, false]]);
const repo = aiSettingsRepo(db, box);
expect(await repo.serverApiKey()).toBe("sk-ant-SERVER");
expect(await repo.mcpServersWithSecrets()).toEqual([
{ id: "fs", name: "Files", transport: "stdio", command: "mcp-fs", args: ["--root", "/x"], env: { FS_TOKEN: "tok-PLAIN-123456" } },
{ id: "web", name: "Web", transport: "http", url: "https://mcp.example.com/mcp", headers: { authorization: "Bearer hdr-PLAIN-abcdef" }, courseIds: ["sample"] },
{ id: "bare", name: "Bare", transport: "http", url: "https://bare.example.com/mcp" },
]);
const shown = JSON.stringify(await repo.getServer());
expect(shown).not.toContain("PLAIN");
});