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

+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" }]);
});