From f2169a1235ac149be1d652cf9d96092f6e9955f2 Mon Sep 17 00:00:00 2001
From: Lucas Winther
Date: Fri, 30 Oct 2026 20:49:04 +0100
Subject: [PATCH] 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
---
packages/feature-ai/src/crypto.ts | 89 +++++++++----
packages/feature-ai/src/feature.ts | 30 +++--
packages/feature-ai/src/index.ts | 2 +-
packages/feature-ai/src/mcp/secrets.ts | 119 ++++++++++++++----
packages/feature-ai/src/settings.ts | 85 ++++++++++---
packages/feature-ai/src/upgrade.ts | 85 ++++++++++---
packages/feature-ai/src/web/AccountAiPage.tsx | 7 +-
packages/feature-ai/src/web/AdminAiPage.tsx | 22 +++-
packages/feature-ai/src/web/components.tsx | 22 ++++
packages/feature-ai/test/crypto.test.ts | 46 +++++--
packages/feature-ai/test/feature.test.ts | 30 +++++
packages/feature-ai/test/settings.test.ts | 95 +++++++++++++-
packages/feature-ai/test/upgrade.test.ts | 90 +++++++++++--
packages/feature-ai/test/web.test.tsx | 17 +++
14 files changed, 612 insertions(+), 127 deletions(-)
diff --git a/packages/feature-ai/src/crypto.ts b/packages/feature-ai/src/crypto.ts
index 10f6456..a139844 100644
--- a/packages/feature-ai/src/crypto.ts
+++ b/packages/feature-ai/src/crypto.ts
@@ -1,41 +1,82 @@
/**
- * Secret box for API keys at rest: AES-GCM-256 with a key derived by HKDF-SHA-256 from STC_SECRET.
- * Token format: `v1..`.
+ * 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..` (v1.1): each purpose has its own HKDF key (`info` =
+ * `stcourses//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..` (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 {
- encrypt(plain: string): Promise;
- decrypt(token: string): Promise;
+ seal(plain: string, purpose: SecretPurpose, aad: string): Promise;
+ open(token: string, purpose: SecretPurpose, aad: string): Promise;
+ /** v1 tokens only; for the data upgrade. */
+ openLegacy(token: string): Promise;
}
const enc = new TextEncoder();
const dec = new TextDecoder();
const b64 = (u: Uint8Array) => Buffer.from(u).toString("base64");
-const unb64 = (s: string) => new Uint8Array(Buffer.from(s, "base64"));
+const unb64 = (s: string): Uint8Array => 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 {
+ 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; ct: Uint8Array } {
+ 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 {
- let keyP: Promise | null = null;
- const key = () =>
- (keyP ??= (async () => {
- const ikm = await crypto.subtle.importKey("raw", enc.encode(secret), "HKDF", false, ["deriveKey"]);
- return crypto.subtle.deriveKey(
- { name: "HKDF", hash: "SHA-256", salt: enc.encode("stcourses/byok"), info: enc.encode("aes-gcm-256/v1") },
- ikm,
- { name: "AES-GCM", length: 256 },
- false,
- ["encrypt", "decrypt"],
- );
- })());
+ const keys = new Map>();
+ const key = (info: string) => {
+ let k = keys.get(info);
+ if (!k) keys.set(info, (k = deriveKey(secret, info)));
+ return k;
+ };
return {
- async encrypt(plain) {
+ async seal(plain, purpose, aad) {
const iv = crypto.getRandomValues(new Uint8Array(12));
- const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, await key(), enc.encode(plain)));
- return `v1.${b64(iv)}.${b64(ct)}`;
+ 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 decrypt(token) {
- const [v, iv, ct] = token.split(".");
- if (v !== "v1" || !iv || !ct) throw new Error("Unrecognised secret format");
- const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv: unb64(iv) }, await key(), unb64(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 {
+ 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}`;
diff --git a/packages/feature-ai/src/feature.ts b/packages/feature-ai/src/feature.ts
index 2803f81..2169bcb 100644
--- a/packages/feature-ai/src/feature.ts
+++ b/packages/feature-ai/src/feature.ts
@@ -91,6 +91,10 @@ function parseMcp(v: unknown): McpServerConfig[] | "invalid" {
if (s.headers && typeof s.headers === "object") cfg.headers = Object.fromEntries(Object.entries(s.headers).map(([k, val]) => [k, String(val)]));
}
if (Array.isArray(s.courseIds)) cfg.courseIds = s.courseIds.map(String).filter(Boolean);
+ if (s.clearSecrets !== undefined) {
+ if (!Array.isArray(s.clearSecrets) || !s.clearSecrets.every((f) => f === "env" || f === "headers")) return "invalid";
+ if (s.clearSecrets.length) cfg.clearSecrets = [...new Set(s.clearSecrets as ("env" | "headers")[])];
+ }
out.push(cfg);
}
return out;
@@ -147,10 +151,16 @@ export function aiFeature(ctx: ServerContext, opts: AiFeatureOptions = {}): Feat
const sink = opts.draftSink ?? createFsDraftSink(ctx.config.dataDir);
const log = opts.log ?? ((m: string) => console.error(`[ai] ${m}`));
// The v1 data upgrade (mentor toggles, MCP secrets) starts now; every settings call also waits for it.
- settings.ready().catch((e) => log(`v1.1 data upgrade failed (retried on next use): ${(e as Error).message}`));
+ // Unreadable secrets are kept, never dropped; say so once at startup (the admin page shows the details).
+ settings.ready()
+ .then(() => settings.getServer())
+ .then((s) => {
+ if (s.unreadableSecrets.length) log(`${s.unreadableSecrets.length} stored AI secret(s) cannot be decrypted with the current STC_SECRET; they are kept until re-entered or the previous secret is restored`);
+ })
+ .catch((e) => log(`v1.1 data upgrade failed (retried on next use): ${(e as Error).message}`));
// AppConfig.aiAllowedBaseUrls (STC_AI_ALLOWED_BASE_URLS) plus the admin's list in the server AI settings.
const configOrigins = parseAllowedOrigins((ctx.config.aiAllowedBaseUrls ?? []).join(","));
- const allowedOrigins = async () => [...new Set([...configOrigins, ...(await settings.getServer()).allowedBaseUrls])];
+ const allowedOrigins = async () => [...new Set([...configOrigins, ...(await settings.allowedBaseUrls())])];
/**
* Providers for a user's own key never follow redirects, and their errors reach the user only as the HTTP
* status plus a fixed sentence (the full text is logged here). Server-scope providers are the admin's own.
@@ -172,12 +182,12 @@ export function aiFeature(ctx: ServerContext, opts: AiFeatureOptions = {}): Feat
if (key && base) return { source: "byok", cfg: { ...u.byok, baseUrl: base }, key };
}
}
- const s = await settings.getServer();
- if (!s.provider) return null;
- if (s.provider.kind === "anthropic" && !s.provider.hasApiKey) return null;
- const key = s.provider.hasApiKey ? await settings.serverApiKey().catch(() => null) : null;
- if (s.provider.kind === "anthropic" && !key) return null;
- return { source: "server", cfg: s.provider, key };
+ const provider = await settings.serverProvider();
+ if (!provider) return null;
+ if (provider.kind === "anthropic" && !provider.hasApiKey) return null;
+ const key = provider.hasApiKey ? await settings.serverApiKey().catch(() => null) : null;
+ if (provider.kind === "anthropic" && !key) return null;
+ return { source: "server", cfg: provider, key };
};
const viewable = async (user: AuthUser | null, courseId: string): Promise => {
@@ -378,8 +388,8 @@ export function aiFeature(ctx: ServerContext, opts: AiFeatureOptions = {}): Feat
GET: async (_req, rc) => {
if (!rc.user) return err(401, "Sign in first.");
const u = await settings.getUser(rc.user.id);
- const s = await settings.getServer();
- return json({ byok: u.byok, serverProvider: s.provider ? { kind: s.provider.kind, model: s.provider.model ?? null } : null });
+ const p = await settings.serverProvider();
+ return json({ byok: u.byok, serverProvider: p ? { kind: p.kind, model: p.model ?? null } : null });
},
},
"/api/ai/account/key": {
diff --git a/packages/feature-ai/src/index.ts b/packages/feature-ai/src/index.ts
index 2582896..d0cae5b 100644
--- a/packages/feature-ai/src/index.ts
+++ b/packages/feature-ai/src/index.ts
@@ -2,7 +2,7 @@ export { createSecretBox, type SecretBox } from "./crypto";
export { migrationsDirs } from "./migrations";
export * from "./settings";
-export { upgradeAiData, AI_DATA_UPGRADE } from "./upgrade";
+export { upgradeAiData, AI_DATA_UPGRADE, AI_SECRETS_UPGRADE } from "./upgrade";
export * from "./providers";
export * from "./retrieval/course-index";
export { Bm25, tokenize } from "./retrieval/bm25";
diff --git a/packages/feature-ai/src/mcp/secrets.ts b/packages/feature-ai/src/mcp/secrets.ts
index 210dc1f..f0ed5d9 100644
--- a/packages/feature-ai/src/mcp/secrets.ts
+++ b/packages/feature-ai/src/mcp/secrets.ts
@@ -1,12 +1,33 @@
-import type { SecretBox } from "../crypto";
+import { mcpAad, type SecretBox } from "../crypto";
import type { McpServerConfig } from "../settings";
/**
- * MCP `env` and `headers` at rest: both move into `secretCipher`, sealed with the same AES-GCM/HKDF box as API
- * keys. The admin API only ever sees masked values; server code opens them to start or call an MCP server.
+ * MCP `env` and `headers` at rest: each field is sealed on its own (`envCipher`, `headersCipher`) with the "mcp"
+ * purpose key and the server id plus field name as additional data, so a cipher moved to another server, field or
+ * table does not open. The admin API only ever sees masked values; server code opens them to start or call a server.
+ *
+ * Older shapes, re-sealed by the data upgrade (src/upgrade.ts): plaintext `env`/`headers` (v1) and one v1-token
+ * `secretCipher` holding `{ env, headers }` (the first v1.1 draft).
*/
-export type StoredMcpServer = Omit & { secretCipher?: string; env?: Record; headers?: Record };
-type Secrets = { env?: Record; headers?: Record };
+export type SecretField = "env" | "headers";
+export const SECRET_FIELDS: SecretField[] = ["env", "headers"];
+const CIPHER: Record = { env: "envCipher", headers: "headersCipher" };
+
+export type StoredMcpServer = Omit & {
+ envCipher?: string;
+ headersCipher?: string;
+ /** First v1.1 draft: v1 token of `{ env, headers }`. */
+ secretCipher?: string;
+ /** v1: plaintext. */
+ env?: Record;
+ headers?: Record;
+};
+
+export interface OpenedMcpServer {
+ cfg: McpServerConfig;
+ /** Fields with a stored cipher that cannot be opened (STC_SECRET changed, or a cipher moved from elsewhere). */
+ unreadable: SecretField[];
+}
export const MASK_PREFIX = "••••";
@@ -23,35 +44,81 @@ export class MaskedSecretError extends Error {
}
}
-const nonEmpty = (r: Record | undefined) => (r && Object.keys(r).length ? r : undefined);
+const nonEmpty = (r: Record | undefined) =>
+ (r && typeof r === "object" && !Array.isArray(r) && Object.keys(r).length ? r : undefined);
-/** Strips `env`/`headers` from a config and seals them (if any) into `secretCipher`. */
-export async function sealMcpServer(box: SecretBox, cfg: McpServerConfig | StoredMcpServer): Promise {
- const { env, headers, secretCipher: _old, ...rest } = cfg as StoredMcpServer;
- const secrets: Secrets = {};
- if (nonEmpty(env)) secrets.env = env;
- if (nonEmpty(headers)) secrets.headers = headers;
- return secrets.env || secrets.headers ? { ...rest, secretCipher: await box.encrypt(JSON.stringify(secrets)) } : rest;
+const isRecordOfStrings = (v: unknown): v is Record =>
+ !!v && typeof v === "object" && !Array.isArray(v) && Object.values(v).every((x) => typeof x === "string");
+
+/** The non-secret part of a server config (also drops `clearSecrets`, which is a save instruction only). */
+function baseOf(s: McpServerConfig | StoredMcpServer): Omit {
+ const { env: _e, headers: _h, envCipher: _ec, headersCipher: _hc, secretCipher: _sc, clearSecrets: _cs, ...rest } = s as StoredMcpServer & McpServerConfig;
+ return rest;
+}
+
+/** Opens a stored server for server code. Unreadable ciphers are reported, never thrown and never discarded. */
+export async function openMcpServer(box: SecretBox, s: StoredMcpServer): Promise {
+ const cfg: McpServerConfig = { ...baseOf(s) };
+ const unreadable: SecretField[] = [];
+ for (const f of SECRET_FIELDS) {
+ const token = s[CIPHER[f]];
+ if (token) {
+ try {
+ const v = JSON.parse(await box.open(token, "mcp", mcpAad(s.id, f)));
+ if (!isRecordOfStrings(v)) throw new Error("not a record");
+ if (nonEmpty(v)) cfg[f] = v;
+ } catch { unreadable.push(f); }
+ } else if (nonEmpty(s[f])) cfg[f] = s[f]; // v1 plaintext not yet upgraded
+ }
+ // A draft-format cipher the upgrade could not open: report the field this transport uses.
+ if (s.secretCipher) {
+ const f: SecretField = s.transport === "stdio" ? "env" : "headers";
+ if (!s[CIPHER[f]] && !unreadable.includes(f)) unreadable.push(f);
+ }
+ return { cfg, unreadable };
}
/**
- * The full config for server code. A value that cannot be opened (STC_SECRET changed) is dropped with the server
- * still listed, so a broken secret never crashes the assistant. Plaintext left over from v1 is passed through.
+ * Seals a posted (already unmasked) config. For each field: `clearSecrets` drops it; posted values replace it; with
+ * nothing posted, a stored cipher that could not be opened is kept as it is, so a temporary STC_SECRET mismatch
+ * never turns into data loss on the next save.
*/
-export async function openMcpServer(box: SecretBox, s: StoredMcpServer): Promise {
- const { secretCipher, env, headers, ...rest } = s;
- let secrets: Secrets = {};
- if (secretCipher) {
- try { secrets = JSON.parse(await box.decrypt(secretCipher)) as Secrets; } catch { secrets = {}; }
+export async function sealMcpServer(box: SecretBox, cfg: McpServerConfig, previous?: { stored: StoredMcpServer; unreadable: SecretField[] }): Promise {
+ const out: StoredMcpServer = { ...baseOf(cfg) };
+ for (const f of SECRET_FIELDS) {
+ if (cfg.clearSecrets?.includes(f)) continue;
+ const values = nonEmpty(cfg[f]);
+ if (values) out[CIPHER[f]] = await box.seal(JSON.stringify(values), "mcp", mcpAad(cfg.id, f));
+ else if (previous?.unreadable.includes(f)) {
+ if (previous.stored[CIPHER[f]]) out[CIPHER[f]] = previous.stored[CIPHER[f]];
+ else if (previous.stored.secretCipher) out.secretCipher = previous.stored.secretCipher;
+ }
}
- const out: McpServerConfig = { ...rest };
- const e = nonEmpty(secrets.env) ?? nonEmpty(env);
- const h = nonEmpty(secrets.headers) ?? nonEmpty(headers);
- if (e) out.env = e;
- if (h) out.headers = h;
return out;
}
+/** Re-seals v1 plaintext and a readable draft `secretCipher` into per-field v2 ciphers. `pending`: something legacy stays. */
+export async function upgradeStoredMcpServer(box: SecretBox, s: StoredMcpServer): Promise<{ server: StoredMcpServer; changed: boolean; pending: boolean }> {
+ let legacy: { env?: Record; headers?: Record } = {};
+ let pending = false;
+ let changed = false;
+ if (s.secretCipher) {
+ try {
+ const v = JSON.parse(await box.openLegacy(s.secretCipher));
+ legacy = v && typeof v === "object" ? v : {};
+ } catch { pending = true; }
+ }
+ const out: StoredMcpServer = { ...baseOf(s) };
+ for (const f of SECRET_FIELDS) {
+ if (s[CIPHER[f]]) { out[CIPHER[f]] = s[CIPHER[f]]; continue; }
+ const values = nonEmpty(isRecordOfStrings(s[f]) ? s[f] : undefined) ?? nonEmpty(isRecordOfStrings(legacy[f]) ? legacy[f] : undefined);
+ if (values) out[CIPHER[f]] = await box.seal(JSON.stringify(values), "mcp", mcpAad(s.id, f));
+ }
+ if (pending) out.secretCipher = s.secretCipher;
+ if (s.env !== undefined || s.headers !== undefined || (s.secretCipher && !pending)) changed = true;
+ return { server: out, changed, pending };
+}
+
const maskRecord = (r: Record | undefined) => (r ? Object.fromEntries(Object.entries(r).map(([k, v]) => [k, maskSecret(v)])) : undefined);
/** What the admin API returns: keys visible, values masked. */
@@ -68,7 +135,7 @@ export function maskMcpServer(s: McpServerConfig): McpServerConfig {
*/
export function unmaskMcpServer(posted: McpServerConfig, previous: McpServerConfig[]): McpServerConfig {
const prev = previous.find((p) => p.id === posted.id);
- const fill = (field: "env" | "headers") => {
+ const fill = (field: SecretField) => {
const r = posted[field];
if (!r) return undefined;
return Object.fromEntries(Object.entries(r).map(([k, v]) => {
diff --git a/packages/feature-ai/src/settings.ts b/packages/feature-ai/src/settings.ts
index c571df4..1e0438a 100644
--- a/packages/feature-ai/src/settings.ts
+++ b/packages/feature-ai/src/settings.ts
@@ -1,6 +1,6 @@
import type { Db } from "@stc/db";
-import type { SecretBox } from "./crypto";
-import { maskMcpServer, openMcpServer, sealMcpServer, unmaskMcpServer, type StoredMcpServer } from "./mcp/secrets";
+import { byokAad, SERVER_KEY_AAD, type SecretBox } from "./crypto";
+import { maskMcpServer, openMcpServer, sealMcpServer, unmaskMcpServer, type OpenedMcpServer, type SecretField, type StoredMcpServer } from "./mcp/secrets";
import { upgradeAiData } from "./upgrade";
export { maskSecret, MaskedSecretError } from "./mcp/secrets";
@@ -21,20 +21,28 @@ export interface McpServerConfig {
headers?: Record;
/** Courses this server is offered in. Absent or empty: every course (global). */
courseIds?: string[];
+ /** Save only: drop these stored fields, even if they cannot be decrypted. Never stored or returned. */
+ clearSecrets?: SecretField[];
}
+/** A stored secret the current STC_SECRET cannot open. It is kept until the admin replaces or clears it. */
+export type UnreadableSecret =
+ | { kind: "api-key" }
+ | { kind: "mcp"; serverId: string; name: string; field: SecretField };
+
/**
* `allowedBaseUrls`: origins learners' own keys (BYOK) may be sent to, besides the official endpoints.
* `mcpServers` here carry masked `env`/`headers` values (see mcp/secrets.ts); full values: mcpServersWithSecrets().
*/
-export interface ServerSettings { provider: PublicProviderConfig | null; mcpServers: McpServerConfig[]; allowedBaseUrls: string[] }
+export interface ServerSettings { provider: PublicProviderConfig | null; mcpServers: McpServerConfig[]; allowedBaseUrls: string[]; unreadableSecrets: UnreadableSecret[] }
export interface ServerSettingsInput {
provider: (ProviderConfig & { apiKey?: string; clearApiKey?: boolean }) | null;
mcpServers: McpServerConfig[];
/** Omitted: keep the saved list. */
allowedBaseUrls?: string[];
}
-export interface UserSettings { byok: PublicProviderConfig | null; mentorOff: Record }
+/** `keyUnreadable`: the saved key cannot be decrypted with the current STC_SECRET; the user must re-enter it. */
+export interface UserSettings { byok: (PublicProviderConfig & { keyUnreadable?: true }) | null; mentorOff: Record }
type ServerRow = { provider: ProviderConfig | null; apiKeyCipher: string | null; mcpServers: StoredMcpServer[]; allowedBaseUrls?: string[] };
type UserRow = { byok: ProviderConfig | null };
@@ -84,7 +92,13 @@ export function aiSettingsRepo(db: Db, box: SecretBox) {
const { mentorOff: _legacy, ...row } = parse(await read("user", userId), { byok: null } as UserRow & { mentorOff?: unknown });
return row;
};
- const openServers = async (r: ServerRow) => Promise.all((r.mcpServers ?? []).map((m) => openMcpServer(box, m)));
+ const storedServers = (r: ServerRow): StoredMcpServer[] =>
+ (Array.isArray(r.mcpServers) ? r.mcpServers : []).filter((m) => m && typeof m === "object" && typeof m.id === "string");
+ const openServers = async (r: ServerRow): Promise => Promise.all(storedServers(r).map((m) => openMcpServer(box, m)));
+ const openServerKey = async (r: ServerRow): Promise =>
+ r.apiKeyCipher ? box.open(r.apiKeyCipher, "server-api-key", SERVER_KEY_AAD).catch(() => null) : null;
+ const openUserKey = async (userId: string, k: { provider: string; ciphertext: string }): Promise =>
+ box.open(k.ciphertext, "byok", byokAad(userId, k.provider)).catch(() => null);
const keyRow = async (userId: string) =>
(await db.sql`SELECT provider, ciphertext FROM user_api_keys WHERE user_id = ${userId}`)[0] as { provider: string; ciphertext: string } | undefined;
@@ -94,30 +108,60 @@ export function aiSettingsRepo(db: Db, box: SecretBox) {
async getServer(): Promise {
await ready();
const r = await serverRow();
- const mcpServers = (await openServers(r)).map(maskMcpServer);
- return { provider: r.provider ? { ...r.provider, hasApiKey: !!r.apiKeyCipher } : null, mcpServers, allowedBaseUrls: r.allowedBaseUrls ?? [] };
+ const opened = await openServers(r);
+ const unreadableSecrets: UnreadableSecret[] = [];
+ if (r.provider && r.apiKeyCipher && (await openServerKey(r)) === null) unreadableSecrets.push({ kind: "api-key" });
+ for (const o of opened) for (const field of o.unreadable) unreadableSecrets.push({ kind: "mcp", serverId: o.cfg.id, name: o.cfg.name, field });
+ return {
+ provider: r.provider ? { ...r.provider, hasApiKey: !!r.apiKeyCipher } : null,
+ mcpServers: opened.map((o) => maskMcpServer(o.cfg)),
+ allowedBaseUrls: r.allowedBaseUrls ?? [],
+ unreadableSecrets,
+ };
},
- /** Server code only: MCP servers with their real env and headers. */
+ /** The admin provider without opening any secret (for resolving which provider a request uses). */
+ async serverProvider(): Promise {
+ await ready();
+ const r = await serverRow();
+ return r.provider ? { ...r.provider, hasApiKey: !!r.apiKeyCipher } : null;
+ },
+ /** The admin's BYOK allowlist, without opening any secret. */
+ async allowedBaseUrls(): Promise {
+ await ready();
+ return (await serverRow()).allowedBaseUrls ?? [];
+ },
+ /** Server code only: MCP servers with their real env and headers (unreadable fields left out). */
async mcpServersWithSecrets(): Promise {
await ready();
- return openServers(await serverRow());
+ return (await openServers(await serverRow())).map((o) => o.cfg);
},
/** A posted (masked) MCP config with stored values filled in; throws MaskedSecretError for a stray mask. */
async resolveMcpMasks(cfg: McpServerConfig): Promise {
await ready();
- return unmaskMcpServer(cfg, await openServers(await serverRow()));
+ const { clearSecrets: _c, ...rest } = unmaskMcpServer(cfg, (await openServers(await serverRow())).map((o) => o.cfg));
+ return rest;
},
+ /**
+ * Saves the admin settings. Secrets that cannot be decrypted are never discarded implicitly: the API key stays
+ * unless replaced or cleared (clearApiKey), and an MCP field stays unless new values are posted or the field is
+ * listed in that server's clearSecrets.
+ */
async saveServer(input: ServerSettingsInput): Promise {
await ready();
const prev = await serverRow();
let apiKeyCipher: string | null = null;
if (input.provider) {
- if (input.provider.apiKey) apiKeyCipher = await box.encrypt(input.provider.apiKey);
+ if (input.provider.apiKey) apiKeyCipher = await box.seal(input.provider.apiKey, "server-api-key", SERVER_KEY_AAD);
else if (!input.provider.clearApiKey) apiKeyCipher = prev.apiKeyCipher;
}
- const previous = await openServers(prev);
- const unmasked = (input.mcpServers ?? []).map((m) => unmaskMcpServer(m, previous));
- const mcpServers = await Promise.all(unmasked.map((m) => sealMcpServer(box, m)));
+ const stored = storedServers(prev);
+ const opened = await openServers(prev);
+ const previous = opened.map((o) => o.cfg);
+ const mcpServers: StoredMcpServer[] = [];
+ for (const m of input.mcpServers ?? []) {
+ const i = stored.findIndex((x) => x.id === m.id);
+ mcpServers.push(await sealMcpServer(box, unmaskMcpServer(m, previous), i >= 0 ? { stored: stored[i]!, unreadable: opened[i]!.unreadable } : undefined));
+ }
await write("server", "", {
provider: input.provider ? pickProvider(input.provider) : null,
apiKeyCipher,
@@ -128,18 +172,23 @@ export function aiSettingsRepo(db: Db, box: SecretBox) {
async serverApiKey(): Promise {
await ready();
const r = await serverRow();
- return r.provider && r.apiKeyCipher ? box.decrypt(r.apiKeyCipher) : null;
+ return r.provider ? openServerKey(r) : null;
},
async getUser(userId: string): Promise {
await ready();
const r = await userRow(userId);
const k = await keyRow(userId);
const prefs = (await db.sql`SELECT course_id FROM ai_mentor_prefs WHERE user_id = ${userId} AND off = 1`) as { course_id: string }[];
- return { byok: r.byok && k ? { ...r.byok, hasApiKey: true } : null, mentorOff: Object.fromEntries(prefs.map((p) => [p.course_id, true])) };
+ let byok: UserSettings["byok"] = null;
+ if (r.byok && k) {
+ byok = { ...r.byok, hasApiKey: true };
+ if ((await openUserKey(userId, k)) === null) byok.keyUnreadable = true;
+ }
+ return { byok, mentorOff: Object.fromEntries(prefs.map((p) => [p.course_id, true])) };
},
async saveUserKey(userId: string, cfg: ProviderConfig & { apiKey: string }): Promise {
await ready();
- const ciphertext = await box.encrypt(cfg.apiKey);
+ const ciphertext = await box.seal(cfg.apiKey, "byok", byokAad(userId, cfg.kind));
const now = new Date().toISOString();
await db.sql.begin(async (tx) => {
await tx`DELETE FROM user_api_keys WHERE user_id = ${userId}`;
@@ -157,7 +206,7 @@ export function aiSettingsRepo(db: Db, box: SecretBox) {
async userApiKey(userId: string): Promise {
await ready();
const k = await keyRow(userId);
- return k ? box.decrypt(k.ciphertext) : null;
+ return k ? openUserKey(userId, k) : null;
},
async setMentorOff(userId: string, courseId: string, off: boolean): Promise {
await ready();
diff --git a/packages/feature-ai/src/upgrade.ts b/packages/feature-ai/src/upgrade.ts
index 1a33600..a5ad34e 100644
--- a/packages/feature-ai/src/upgrade.ts
+++ b/packages/feature-ai/src/upgrade.ts
@@ -1,9 +1,11 @@
import type { Db } from "@stc/db";
-import type { SecretBox } from "./crypto";
-import { sealMcpServer, type StoredMcpServer } from "./mcp/secrets";
+import { byokAad, isLegacyToken, SERVER_KEY_AAD, type SecretBox } from "./crypto";
+import { upgradeStoredMcpServer, type StoredMcpServer } from "./mcp/secrets";
/** Recorded in schema_migrations once the data upgrade has run (no `.sql` suffix, so no file can share it). */
export const AI_DATA_UPGRADE = "ai_data_v11";
+/** Recorded once no secret is left in a pre-v2 format. */
+export const AI_SECRETS_UPGRADE = "ai_secrets_v2";
type Tx = Bun.TransactionSQL;
@@ -31,28 +33,79 @@ async function moveMentorToggles(tx: Tx, now: string) {
}
}
-/** v1 stored MCP `env`/`headers` in plaintext in the server row; seal them like API keys. */
-async function sealMcpSecrets(tx: Tx, box: SecretBox, now: string) {
+/**
+ * Re-seals every secret still in an older format as a v2 token (own HKDF key per purpose, AAD bound to the row):
+ * BYOK keys, the server API key, and MCP env/headers (v1 plaintext or the draft `secretCipher`). A v1 token that
+ * does not open (STC_SECRET changed) is left untouched and the step stays pending, so it is retried on every start
+ * and completes once the previous secret is restored. Returns { changed, pending }.
+ */
+async function resealSecrets(tx: Tx, box: SecretBox, now: string): Promise<{ changed: boolean; pending: boolean }> {
+ let changed = false;
+ let pending = false;
+ const keys = (await tx`SELECT user_id, provider, ciphertext FROM user_api_keys`) as { user_id: string; provider: string; ciphertext: string }[];
+ for (const k of keys) {
+ if (!isLegacyToken(k.ciphertext)) continue;
+ let plain: string;
+ try { plain = await box.openLegacy(k.ciphertext); } catch { pending = true; continue; }
+ const sealed = await box.seal(plain, "byok", byokAad(k.user_id, k.provider));
+ await tx`UPDATE user_api_keys SET ciphertext = ${sealed} WHERE user_id = ${k.user_id} AND provider = ${k.provider}`;
+ changed = true;
+ }
+
const rows = (await tx`SELECT config_json FROM ai_settings WHERE scope = 'server' AND owner_id = ''`) as { config_json: string }[];
const cfg = rows[0] ? parseObject(rows[0].config_json) : null;
- if (!cfg || !Array.isArray(cfg.mcpServers)) return;
- const servers = cfg.mcpServers as StoredMcpServer[];
- if (!servers.some((s) => s && (s.env || s.headers))) return;
- const mcpServers = await Promise.all(servers.map((s) => (s && (s.env || s.headers) ? sealMcpServer(box, s) : s)));
- await tx`UPDATE ai_settings SET config_json = ${JSON.stringify({ ...cfg, mcpServers })}, updated_at = ${now} WHERE scope = 'server' AND owner_id = ''`;
+ if (!cfg) return { changed, pending };
+ let rowChanged = false;
+ if (typeof cfg.apiKeyCipher === "string" && isLegacyToken(cfg.apiKeyCipher)) {
+ try {
+ cfg.apiKeyCipher = await box.seal(await box.openLegacy(cfg.apiKeyCipher), "server-api-key", SERVER_KEY_AAD);
+ rowChanged = true;
+ } catch { pending = true; }
+ }
+ if (Array.isArray(cfg.mcpServers)) {
+ const servers: unknown[] = [];
+ for (const s of cfg.mcpServers) {
+ // Malformed entries are kept as they are (the settings code skips them); they must not stop the upgrade.
+ if (!s || typeof s !== "object" || typeof (s as StoredMcpServer).id !== "string") { servers.push(s); continue; }
+ const r = await upgradeStoredMcpServer(box, s as StoredMcpServer);
+ servers.push(r.server);
+ if (r.changed) rowChanged = true;
+ if (r.pending) pending = true;
+ }
+ cfg.mcpServers = servers;
+ }
+ if (rowChanged) {
+ await tx`UPDATE ai_settings SET config_json = ${JSON.stringify(cfg)}, updated_at = ${now} WHERE scope = 'server' AND owner_id = ''`;
+ changed = true;
+ }
+ return { changed, pending };
}
/**
- * Data the v1.1 SQL migrations cannot move portably. Runs after the package's migrations, in one transaction,
- * once (marked in schema_migrations). Returns true if it ran now. The settings repo awaits it before first use.
+ * Data the v1.1 SQL migrations cannot move portably, in one transaction. Each step is recorded in schema_migrations
+ * when complete (names without `.sql`, so no file can share them):
+ * - `ai_data_v11`: per-course mentor toggles from config_json to ai_mentor_prefs;
+ * - `ai_secrets_v2`: every secret re-sealed in the v2 format (marked only when nothing legacy is left).
+ * Returns true if it changed anything now. The settings repo awaits it before first use.
*/
export async function upgradeAiData(db: Db, box: SecretBox): Promise {
return db.sql.begin(async (tx) => {
- if ((await tx`SELECT name FROM schema_migrations WHERE name = ${AI_DATA_UPGRADE}`).length) return false;
+ const done = new Set(((await tx`SELECT name FROM schema_migrations WHERE name IN (${AI_DATA_UPGRADE}, ${AI_SECRETS_UPGRADE})`) as { name: string }[]).map((r) => r.name));
const now = new Date().toISOString();
- await moveMentorToggles(tx, now);
- await sealMcpSecrets(tx, box, now);
- await tx`INSERT INTO schema_migrations (name, applied_at) VALUES (${AI_DATA_UPGRADE}, ${now})`;
- return true;
+ let changed = false;
+ if (!done.has(AI_DATA_UPGRADE)) {
+ await moveMentorToggles(tx, now);
+ await tx`INSERT INTO schema_migrations (name, applied_at) VALUES (${AI_DATA_UPGRADE}, ${now})`;
+ changed = true;
+ }
+ if (!done.has(AI_SECRETS_UPGRADE)) {
+ const r = await resealSecrets(tx, box, now);
+ if (r.changed) changed = true;
+ if (!r.pending) {
+ await tx`INSERT INTO schema_migrations (name, applied_at) VALUES (${AI_SECRETS_UPGRADE}, ${now})`;
+ changed = true;
+ }
+ }
+ return changed;
});
}
diff --git a/packages/feature-ai/src/web/AccountAiPage.tsx b/packages/feature-ai/src/web/AccountAiPage.tsx
index 137cfe8..79fd713 100644
--- a/packages/feature-ai/src/web/AccountAiPage.tsx
+++ b/packages/feature-ai/src/web/AccountAiPage.tsx
@@ -1,10 +1,10 @@
import { useEffect, useState } from "react";
import { Pill } from "@stc/ui";
import { api, ApiError } from "./api";
-import { Field, PageHead } from "./components";
+import { Field, PageHead, UNREADABLE_KEY_TEXT } from "./components";
interface Account {
- byok: { kind: "anthropic" | "openai"; model?: string; baseUrl?: string; hasApiKey: boolean } | null;
+ byok: { kind: "anthropic" | "openai"; model?: string; baseUrl?: string; hasApiKey: boolean; keyUnreadable?: boolean } | null;
serverProvider: { kind: string; model: string | null } | null;
}
@@ -51,7 +51,8 @@ export default function AccountAiPage() {
- {acct.byok ? A {acct.byok.kind === "anthropic" ? "Anthropic" : "OpenAI-compatible"} key is saved{acct.byok.model ? ` (model ${acct.byok.model})` : ""}.
: null}
+ {acct.byok?.keyUnreadable ? {UNREADABLE_KEY_TEXT}
: null}
+ {acct.byok && !acct.byok.keyUnreadable ? A {acct.byok.kind === "anthropic" ? "Anthropic" : "OpenAI-compatible"} key is saved{acct.byok.model ? ` (model ${acct.byok.model})` : ""}.
: null}