feat: rebuild xiteng.site homelab platform

This commit is contained in:
2026-08-12 09:48:25 +08:00
parent 557b0eca33
commit 5b84988789
128 changed files with 14979 additions and 292 deletions
+22
View File
@@ -0,0 +1,22 @@
# Key Vault and Provider Registry
This internal service is the credential boundary between Authentik identities and Xiteng Chat. It stores
Backend Provider credentials with AES-256-GCM envelope encryption. Every credential has a random data-encryption
key; the master key wraps only those data keys, so master-key rotation is independent of provider-key rotation.
`providers.json` is the built-in Provider Catalog. Administrators can add Custom Providers in SQLite without
editing the built-in file. A provider declares its API protocol, frontend/backend connection type, endpoint,
optional proxy, authentication shape, default-model preference, and a model-discovery type plus URL. The Vault
does not store model lists; Xiteng Chat fetches them dynamically from each configured Provider.
The service is internal-only. `xiteng.site` and `xiteng-chat` sign requests with a dedicated service token after
Authentik checks. The Vault independently treats only the exact username `liooil` as an administrator. Other
identities can access only records whose `(issuer, sub)` pair matches their session.
Normal metadata APIs never return plaintext. The dedicated `/v1/resolve` endpoint is restricted to the exact
service name `xiteng-chat`; it decrypts one active Backend Credential owned by the signed user and returns it to
the Chat server for one provider request. Frontend Provider credentials never enter this service and remain in
the browser's IndexedDB.
Xiteng Chat connects directly: Backend Providers from the Chat server, Frontend Providers from the browser.
Vault audit records cover credential lifecycle, verification, reassignment, resolution, and Custom Provider changes.
+77
View File
@@ -0,0 +1,77 @@
import {existsSync} from "node:fs";
import {loadBuiltinProviders} from "./providers.mjs";
import {breakGlassActor, loadKeyFile, Vault} from "./vault.mjs";
const databasePath = process.env.DATABASE_PATH || "/data/vault.db";
const masterKeyFile = process.env.MASTER_KEY_FILE || "/run/secrets/vault_master_key";
const [command, ...args] = process.argv.slice(2);
function usage() {
console.log(`Usage:
node cli.mjs list [owner-sub]
node cli.mjs audit [limit]
node cli.mjs verify <credential-id>
node cli.mjs delete <credential-id>
node cli.mjs reassign <credential-id> <issuer> <sub> <username>
node cli.mjs rotate-master <new-key-file>
node cli.mjs backup <destination.db>`);
}
if (!command) {
usage();
process.exit(1);
}
const vault = new Vault({
databasePath,
masterKey: loadKeyFile(masterKeyFile),
builtinProviders: loadBuiltinProviders(process.env.PROVIDER_CATALOG_FILE || "/app/providers.json")
});
const actor = breakGlassActor();
try {
if (command === "list") {
const credentials = vault.listCredentials(actor, {all: true});
const filtered = args[0]
? credentials.filter((credential) => credential.owner.sub === args[0])
: credentials;
console.table(filtered.map((credential) => ({
id: credential.id,
owner: credential.owner.username,
sub: credential.owner.sub,
provider: credential.provider,
name: credential.name,
fingerprint: credential.fingerprint,
status: credential.status
})));
} else if (command === "audit") {
console.table(vault.listAudit(actor, {all: true, limit: args[0] || 100}));
} else if (command === "verify" && args[0]) {
console.log(vault.verifyCredential(actor, args[0]));
} else if (command === "delete" && args[0]) {
console.log(vault.deleteCredential(actor, args[0]));
} else if (command === "reassign" && args.length >= 4) {
console.log(vault.reassignCredential(actor, args[0], {
issuer: args[1],
sub: args[2],
username: args[3],
type: "user"
}));
} else if (command === "rotate-master" && args[0]) {
if (!existsSync(args[0])) {
throw new Error(`New key file not found: ${args[0]}`);
}
console.log(vault.rotateMasterKey(actor, loadKeyFile(args[0])));
console.error("Rotation succeeded. Replace the mounted vault_master_key before restarting the service.");
} else if (command === "backup" && args[0]) {
if (existsSync(args[0])) {
throw new Error(`Refusing to overwrite existing backup: ${args[0]}`);
}
console.log(vault.backup(args[0]));
} else {
usage();
process.exitCode = 1;
}
} finally {
vault.close();
}
+50
View File
@@ -0,0 +1,50 @@
services:
ai-gateway:
image: node:24-alpine
container_name: ai-gateway
restart: unless-stopped
command: ["node", "/app/server.mjs"]
environment:
PORT: "8093"
DATABASE_PATH: /data/vault.db
MASTER_KEY_FILE: /run/secrets/vault_master_key
SERVICE_TOKEN_FILE: /run/secrets/portal_gateway_hmac
PROVIDER_CATALOG_FILE: /app/providers.json
ADMIN_USERNAME: liooil
volumes:
- ./vault.mjs:/app/vault.mjs:ro
- ./server.mjs:/app/server.mjs:ro
- ./providers.mjs:/app/providers.mjs:ro
- ./providers.json:/app/providers.json:ro
- ./cli.mjs:/app/cli.mjs:ro
- ./data:/data
- ./secrets:/run/secrets:ro
read_only: true
security_opt:
- no-new-privileges:true
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8093/healthz').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
interval: 15s
timeout: 5s
retries: 3
start_period: 5s
networks:
- homelab_net
labels:
- "traefik.enable=false"
- "xiteng.site.component.ai-gateway.enabled=true"
- "xiteng.site.component.ai-gateway.name=Key Vault"
- "xiteng.site.component.ai-gateway.description=按用户保存 Backend Provider 凭据,并提供内置与 Custom Provider Registry。"
- "xiteng.site.component.ai-gateway.section=infrastructure"
- "xiteng.site.component.ai-gateway.category=身份与访问"
- "xiteng.site.component.ai-gateway.access=internal"
- "xiteng.site.component.ai-gateway.access-label=仅通过 Portal/API 使用"
- "xiteng.site.component.ai-gateway.icon=KV"
- "xiteng.site.component.ai-gateway.accent=blue"
- "xiteng.site.component.ai-gateway.order=220"
- "xiteng.site.component.ai-gateway.monitor.enabled=true"
- "xiteng.site.component.ai-gateway.monitor.url=http://ai-gateway:8093/healthz"
networks:
homelab_net:
external: true
+462
View File
@@ -0,0 +1,462 @@
[
{
"id": "openai",
"name": "OpenAI",
"api": "openai-responses",
"connection": {
"type": "backend",
"baseUrl": "https://api.openai.com/v1",
"proxy": {
"type": "socks5",
"url": "socks5://provider-proxy-bridge:17897"
}
},
"auth": {
"type": "bearer"
},
"defaultModel": "gpt-5.4"
},
{
"id": "anthropic",
"name": "Anthropic",
"api": "anthropic-messages",
"connection": {
"type": "backend",
"baseUrl": "https://api.anthropic.com",
"proxy": {
"type": "socks5",
"url": "socks5://provider-proxy-bridge:17897"
}
},
"auth": {
"type": "header",
"header": "x-api-key"
},
"defaultModel": "claude-sonnet-4-6"
},
{
"id": "google",
"name": "Google Gemini",
"api": "google-generative-ai",
"connection": {
"type": "backend",
"baseUrl": "https://generativelanguage.googleapis.com/v1beta",
"proxy": null
},
"auth": {
"type": "header",
"header": "x-goog-api-key"
},
"defaultModel": "gemini-3.1-pro-preview"
},
{
"id": "openrouter",
"name": "OpenRouter",
"api": "openai-completions",
"connection": {
"type": "backend",
"baseUrl": "https://openrouter.ai/api/v1",
"proxy": null
},
"auth": {
"type": "bearer"
},
"headers": {
"HTTP-Referer": "https://chat.xiteng.site",
"X-Title": "Xiteng Chat"
},
"defaultModel": "anthropic/claude-sonnet-4.6"
},
{
"id": "rust.cat",
"name": "rust.cat",
"api": "openai-responses",
"connection": {
"type": "backend",
"baseUrl": "https://rust.cat/codex/v1",
"proxy": null
},
"auth": {
"type": "bearer"
},
"defaultModel": "gpt-5.3-codex"
},
{
"id": "deepseek",
"name": "DeepSeek",
"api": "openai-completions",
"connection": {
"type": "backend",
"baseUrl": "https://api.deepseek.com",
"proxy": null
},
"auth": {
"type": "bearer"
},
"defaultModel": "deepseek-chat"
},
{
"id": "groq",
"name": "Groq",
"api": "openai-completions",
"connection": {
"type": "backend",
"baseUrl": "https://api.groq.com/openai/v1",
"proxy": null
},
"auth": {
"type": "bearer"
},
"defaultModel": "openai/gpt-oss-120b"
},
{
"id": "mistral",
"name": "Mistral AI",
"api": "openai-completions",
"connection": {
"type": "backend",
"baseUrl": "https://api.mistral.ai/v1",
"proxy": null
},
"auth": {
"type": "bearer"
},
"defaultModel": "mistral-large-latest"
},
{
"id": "xai",
"name": "xAI",
"api": "openai-completions",
"connection": {
"type": "backend",
"baseUrl": "https://api.x.ai/v1",
"proxy": null
},
"auth": {
"type": "bearer"
},
"defaultModel": "grok-4"
},
{
"id": "moonshot",
"name": "Moonshot AI",
"api": "openai-completions",
"connection": {
"type": "backend",
"baseUrl": "https://api.moonshot.cn/v1",
"proxy": null
},
"auth": {
"type": "bearer"
},
"defaultModel": "kimi-k3"
},
{
"id": "siliconflow",
"name": "SiliconFlow",
"api": "openai-completions",
"connection": {
"type": "backend",
"baseUrl": "https://api.siliconflow.com/v1",
"proxy": null
},
"auth": {
"type": "bearer"
},
"defaultModel": "deepseek-ai/DeepSeek-V3.2"
},
{
"id": "siliconflow-cn",
"name": "SiliconFlow CN",
"api": "openai-completions",
"connection": {
"type": "backend",
"baseUrl": "https://api.siliconflow.cn/v1",
"proxy": null
},
"auth": {
"type": "bearer"
},
"defaultModel": "deepseek-ai/DeepSeek-V3.2"
},
{
"id": "minimax",
"name": "MiniMax",
"api": "openai-completions",
"connection": {
"type": "backend",
"baseUrl": "https://api.minimax.io/v1",
"proxy": null
},
"auth": {
"type": "bearer"
},
"defaultModel": "MiniMax-M2.1"
},
{
"id": "zai",
"name": "Z.AI",
"api": "openai-completions",
"connection": {
"type": "backend",
"baseUrl": "https://api.z.ai/api/paas/v4",
"proxy": null
},
"auth": {
"type": "bearer"
},
"defaultModel": "glm-5"
},
{
"id": "qianfan",
"name": "Baidu Qianfan",
"api": "openai-completions",
"connection": {
"type": "backend",
"baseUrl": "https://qianfan.baidubce.com/v2",
"proxy": null
},
"auth": {
"type": "bearer"
},
"defaultModel": "ernie-4.5-8k-preview"
},
{
"id": "dashscope",
"name": "Alibaba DashScope",
"api": "openai-completions",
"connection": {
"type": "backend",
"baseUrl": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"proxy": null
},
"auth": {
"type": "bearer"
},
"defaultModel": "qwen3.5-plus"
},
{
"id": "together",
"name": "Together AI",
"api": "openai-completions",
"connection": {
"type": "backend",
"baseUrl": "https://api.together.xyz/v1",
"proxy": null
},
"auth": {
"type": "bearer"
},
"defaultModel": "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8"
},
{
"id": "fireworks",
"name": "Fireworks AI",
"api": "openai-completions",
"connection": {
"type": "backend",
"baseUrl": "https://api.fireworks.ai/inference/v1",
"proxy": null
},
"auth": {
"type": "bearer"
},
"defaultModel": "accounts/fireworks/models/deepseek-v3p2"
},
{
"id": "cerebras",
"name": "Cerebras",
"api": "openai-completions",
"connection": {
"type": "backend",
"baseUrl": "https://api.cerebras.ai/v1",
"proxy": null
},
"auth": {
"type": "bearer"
},
"defaultModel": "gpt-oss-120b"
},
{
"id": "nvidia",
"name": "NVIDIA NIM",
"api": "openai-completions",
"connection": {
"type": "backend",
"baseUrl": "https://integrate.api.nvidia.com/v1",
"proxy": null
},
"auth": {
"type": "bearer"
},
"defaultModel": "nvidia/llama-3.1-nemotron-ultra-253b-v1"
},
{
"id": "huggingface",
"name": "Hugging Face",
"api": "openai-completions",
"connection": {
"type": "backend",
"baseUrl": "https://router.huggingface.co/v1",
"proxy": null
},
"auth": {
"type": "bearer"
},
"defaultModel": "deepseek-ai/DeepSeek-V3.2"
},
{
"id": "novita",
"name": "Novita AI",
"api": "openai-completions",
"connection": {
"type": "backend",
"baseUrl": "https://api.novita.ai/openai",
"proxy": null
},
"auth": {
"type": "bearer"
},
"defaultModel": "deepseek/deepseek-v3.2"
},
{
"id": "aimlapi",
"name": "AIML API",
"api": "openai-completions",
"connection": {
"type": "backend",
"baseUrl": "https://api.aimlapi.com/v1",
"proxy": null
},
"auth": {
"type": "bearer"
},
"defaultModel": "gpt-5.4"
},
{
"id": "venice",
"name": "Venice AI",
"api": "openai-completions",
"connection": {
"type": "backend",
"baseUrl": "https://api.venice.ai/api/v1",
"proxy": null
},
"auth": {
"type": "bearer"
},
"defaultModel": "llama-3.3-70b"
},
{
"id": "nanogpt",
"name": "NanoGPT",
"api": "openai-completions",
"connection": {
"type": "backend",
"baseUrl": "https://nano-gpt.com/api/v1",
"proxy": null
},
"auth": {
"type": "bearer"
},
"defaultModel": "gpt-5.4"
},
{
"id": "vercel-ai-gateway",
"name": "Vercel AI Gateway",
"api": "openai-completions",
"connection": {
"type": "backend",
"baseUrl": "https://ai-gateway.vercel.sh/v1",
"proxy": null
},
"auth": {
"type": "bearer"
},
"defaultModel": "openai/gpt-5.4"
},
{
"id": "cloudflare-ai-gateway",
"name": "Cloudflare AI Gateway",
"api": "openai-completions",
"connection": {
"type": "backend",
"baseUrl": "https://gateway.ai.cloudflare.com/v1",
"proxy": null
},
"auth": {
"type": "bearer"
},
"defaultModel": "openai/gpt-5.4"
},
{
"id": "litellm",
"name": "LiteLLM",
"api": "openai-completions",
"connection": {
"type": "backend",
"baseUrl": "http://litellm:4000/v1",
"proxy": null
},
"auth": {
"type": "bearer"
},
"defaultModel": "gpt-5.4"
},
{
"id": "ollama",
"name": "Ollama",
"api": "openai-completions",
"connection": {
"type": "frontend",
"baseUrl": "http://127.0.0.1:11434/v1",
"proxy": null
},
"auth": {
"type": "none"
},
"defaultModel": "qwen3:8b"
},
{
"id": "lm-studio",
"name": "LM Studio",
"api": "openai-completions",
"connection": {
"type": "frontend",
"baseUrl": "http://127.0.0.1:1234/v1",
"proxy": null
},
"auth": {
"type": "none"
},
"defaultModel": "local-model"
},
{
"id": "llama.cpp",
"name": "llama.cpp",
"api": "openai-completions",
"connection": {
"type": "frontend",
"baseUrl": "http://127.0.0.1:8080/v1",
"proxy": null
},
"auth": {
"type": "none"
},
"defaultModel": "local-model"
},
{
"id": "vllm",
"name": "vLLM",
"api": "openai-completions",
"connection": {
"type": "frontend",
"baseUrl": "http://127.0.0.1:8000/v1",
"proxy": null
},
"auth": {
"type": "none"
},
"defaultModel": "local-model"
}
]
+97
View File
@@ -0,0 +1,97 @@
import {readFileSync} from "node:fs";
const allowedApis = new Set([
"openai-completions",
"openai-responses",
"anthropic-messages",
"google-generative-ai"
]);
function stringValue(value, field, maximum = 200) {
if (typeof value !== "string" || !value.trim()) throw new Error(`${field} is required`);
return value.trim().slice(0, maximum);
}
function optionalUrl(value, field, protocols) {
if (value === null || value === undefined || value === "") return null;
const url = new URL(stringValue(value, field, 2000));
if (!protocols.includes(url.protocol)) throw new Error(`${field} uses an unsupported protocol`);
return url.toString().replace(/\/$/, "");
}
function normalizeHeaders(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
return Object.fromEntries(Object.entries(value).map(([name, headerValue]) => [
stringValue(name, "header name", 120),
stringValue(headerValue, `header ${name}`, 1000)
]));
}
export function normalizeProvider(input, {builtin = false} = {}) {
if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("Provider definition is required");
const id = stringValue(input.id, "provider.id", 80).toLowerCase();
if (!/^[a-z0-9][a-z0-9._-]*$/.test(id)) throw new Error("provider.id contains unsupported characters");
const api = stringValue(input.api, "provider.api", 80);
if (!allowedApis.has(api)) throw new Error(`Unsupported provider API: ${api}`);
const connectionType = input.connection?.type === "frontend" ? "frontend" : "backend";
const proxyInput = input.connection?.proxy;
let proxy = null;
if (proxyInput) {
const proxyType = stringValue(proxyInput.type, "provider.connection.proxy.type", 30);
const allowedProxyTypes = connectionType === "frontend" ? ["relay"] : ["http", "https", "socks5"];
if (!allowedProxyTypes.includes(proxyType)) throw new Error(`Unsupported ${connectionType} proxy type: ${proxyType}`);
proxy = {
type: proxyType,
url: optionalUrl(proxyInput.url, "provider.connection.proxy.url", proxyType === "socks5" ? ["socks5:"] : ["http:", "https:"])
};
}
const authType = ["bearer", "header", "none"].includes(input.auth?.type) ? input.auth.type : "bearer";
const auth = {
type: authType,
...(authType === "header" ? {header: stringValue(input.auth?.header, "provider.auth.header", 120)} : {})
};
const defaultModel = typeof input.defaultModel === "string" ? input.defaultModel.trim().slice(0, 300) : "";
if (!builtin && !defaultModel) throw new Error("provider.defaultModel is required");
const baseUrl = optionalUrl(input.connection?.baseUrl, "provider.connection.baseUrl", ["http:", "https:"]);
const inferredDiscoveryType = api === "anthropic-messages"
? "anthropic-models-list"
: api === "google-generative-ai" ? "google-models-list" : "openai-models-list";
const discoveryType = ["openai-models-list", "anthropic-models-list", "google-models-list"].includes(input.discovery?.type)
? input.discovery.type
: inferredDiscoveryType;
const inferredDiscoveryUrl = discoveryType === "anthropic-models-list"
? `${baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`}/models?limit=200`
: discoveryType === "google-models-list" ? `${baseUrl}/models?pageSize=200` : `${baseUrl}/models`;
const discovery = {
type: discoveryType,
url: optionalUrl(input.discovery?.url, "provider.discovery.url", ["http:", "https:"]) || inferredDiscoveryUrl
};
return {
id,
name: stringValue(input.name, "provider.name", 160),
api,
connection: {
type: connectionType,
baseUrl,
proxy
},
auth,
headers: normalizeHeaders(input.headers),
defaultModel,
discovery,
builtin
};
}
export function loadBuiltinProviders(path) {
const definitions = JSON.parse(readFileSync(path, "utf8"));
if (!Array.isArray(definitions)) throw new Error("Provider catalog must be an array");
const providers = new Map();
for (const definition of definitions) {
const provider = normalizeProvider(definition, {builtin: true});
if (providers.has(provider.id)) throw new Error(`Duplicate built-in provider: ${provider.id}`);
providers.set(provider.id, provider);
}
return providers;
}
+9
View File
@@ -0,0 +1,9 @@
# Runtime secrets
`../../homelab-emergency init-secrets` creates these untracked files:
- `vault_master_key`: 32-byte base64 key used only to wrap per-credential data keys.
- `portal_gateway_hmac`: 32-byte base64 key used to authenticate Portal-to-Gateway requests.
Back up both outside this repository. Never store either key in `vault.db`, Authentik, Docker labels,
or the Portal UI.
+183
View File
@@ -0,0 +1,183 @@
import {timingSafeEqual} from "node:crypto";
import {readFileSync} from "node:fs";
import http from "node:http";
import {loadBuiltinProviders, normalizeProvider} from "./providers.mjs";
import {loadKeyFile, Vault} from "./vault.mjs";
const port = Number.parseInt(process.env.PORT || "8093", 10);
const databasePath = process.env.DATABASE_PATH || "/data/vault.db";
const masterKeyFile = process.env.MASTER_KEY_FILE || "/run/secrets/vault_master_key";
const serviceTokenFile = process.env.SERVICE_TOKEN_FILE || "/run/secrets/portal_gateway_hmac";
const providerCatalogFile = process.env.PROVIDER_CATALOG_FILE || "/app/providers.json";
const adminUsername = process.env.ADMIN_USERNAME || "liooil";
const serviceToken = readFileSync(serviceTokenFile, "utf8").trim();
const vault = new Vault({
databasePath,
masterKey: loadKeyFile(masterKeyFile),
builtinProviders: loadBuiltinProviders(providerCatalogFile)
});
const securityHeaders = {
"Cache-Control": "no-store",
"Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY"
};
function sendJson(response, statusCode, payload) {
const body = JSON.stringify(payload);
response.writeHead(statusCode, {
...securityHeaders,
"Content-Type": "application/json; charset=utf-8",
"Content-Length": Buffer.byteLength(body)
});
response.end(body);
}
async function readBody(request) {
const chunks = [];
let size = 0;
for await (const chunk of request) {
size += chunk.length;
if (size > 1048576) {
const error = new Error("Request body too large");
error.statusCode = 413;
throw error;
}
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
function parseJson(body) {
if (!body.length) return {};
try {
return JSON.parse(body.toString("utf8"));
} catch {
const error = new Error("Invalid JSON body");
error.statusCode = 400;
throw error;
}
}
function safeEqual(left, right) {
const a = Buffer.from(left || "");
const b = Buffer.from(right || "");
return a.length === b.length && a.length > 0 && timingSafeEqual(a, b);
}
function authenticateService(request) {
const authorization = request.headers.authorization || "";
const token = authorization.startsWith("Bearer ") ? authorization.slice(7).trim() : "";
if (!safeEqual(token, serviceToken)) return null;
const issuer = String(request.headers["x-key-vault-actor-issuer"] || "").trim();
const sub = String(request.headers["x-key-vault-actor-sub"] || "").trim();
const username = String(request.headers["x-key-vault-actor-username"] || "").trim();
if (!issuer || !sub || !username) return null;
return {issuer, sub, username, admin: username === adminUsername};
}
const server = http.createServer(async (request, response) => {
try {
if (!request.url) {
sendJson(response, 400, {error: "Bad request"});
return;
}
const url = new URL(request.url, "http://localhost");
const pathname = url.pathname;
if (request.method === "GET" && pathname === "/healthz") {
sendJson(response, 200, {status: "ok", vault: "sealed-at-rest"});
return;
}
const actor = authenticateService(request);
if (!actor) {
sendJson(response, 401, {error: "Trusted service authentication required"});
return;
}
const body = ["POST", "PUT", "PATCH"].includes(request.method || "")
? await readBody(request)
: Buffer.alloc(0);
if (request.method === "GET" && pathname === "/v1/session") {
sendJson(response, 200, {actor});
return;
}
if (request.method === "GET" && pathname === "/v1/providers") {
sendJson(response, 200, {providers: vault.listProviders(actor)});
return;
}
if (request.method === "POST" && pathname === "/v1/providers") {
const provider = normalizeProvider(parseJson(body), {builtin: false});
sendJson(response, 200, {provider: vault.saveCustomProvider(actor, provider)});
return;
}
const providerDelete = pathname.match(/^\/v1\/providers\/([a-z0-9._-]+)\/delete$/);
if (request.method === "POST" && providerDelete) {
sendJson(response, 200, vault.deleteCustomProvider(actor, providerDelete[1]));
return;
}
if (request.method === "GET" && pathname === "/v1/credentials") {
sendJson(response, 200, {
credentials: vault.listCredentials(actor, {all: actor.admin && url.searchParams.get("scope") === "all"})
});
return;
}
if (request.method === "POST" && pathname === "/v1/credentials") {
sendJson(response, 201, {credential: vault.createCredential(actor, parseJson(body))});
return;
}
const credentialAction = pathname.match(/^\/v1\/credentials\/([0-9a-f-]+)\/(replace|verify|delete)$/);
if (request.method === "POST" && credentialAction) {
const [, id, action] = credentialAction;
if (action === "replace") {
sendJson(response, 200, {credential: vault.replaceCredential(actor, id, parseJson(body))});
} else if (action === "verify") {
sendJson(response, 200, vault.verifyCredential(actor, id));
} else {
sendJson(response, 200, vault.deleteCredential(actor, id));
}
return;
}
if (request.method === "POST" && pathname === "/v1/resolve") {
sendJson(response, 200, vault.resolve(actor, parseJson(body)));
return;
}
if (request.method === "GET" && pathname === "/v1/audit") {
sendJson(response, 200, {
events: vault.listAudit(actor, {
all: actor.admin && url.searchParams.get("scope") === "all",
limit: url.searchParams.get("limit")
})
});
return;
}
sendJson(response, 404, {error: "Not found"});
} catch (error) {
console.error("Key Vault request failed", error.message);
sendJson(response, error.statusCode || 400, {error: error.message || "Request failed"});
}
});
server.listen(port, "0.0.0.0", () => {
console.log(`key-vault listening on :${port}`);
});
function shutdown() {
server.close(() => {
vault.close();
process.exit(0);
});
}
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
+110
View File
@@ -0,0 +1,110 @@
import assert from "node:assert/strict";
import {spawn} from "node:child_process";
import {randomBytes} from "node:crypto";
import {mkdtempSync, rmSync, writeFileSync} from "node:fs";
import http from "node:http";
import {tmpdir} from "node:os";
import {join} from "node:path";
const directory = mkdtempSync(join(tmpdir(), "xiteng-key-vault-api-test-"));
writeFileSync(join(directory, "master-key"), randomBytes(32).toString("base64"));
writeFileSync(join(directory, "service-token"), "integration-service-token");
const portProbe = http.createServer();
await new Promise((resolve) => portProbe.listen(0, "127.0.0.1", resolve));
const port = portProbe.address().port;
await new Promise((resolve) => portProbe.close(resolve));
const service = spawn(process.execPath, ["server.mjs"], {
cwd: new URL(".", import.meta.url),
env: {
...process.env,
PORT: String(port),
DATABASE_PATH: join(directory, "vault.db"),
MASTER_KEY_FILE: join(directory, "master-key"),
SERVICE_TOKEN_FILE: join(directory, "service-token"),
PROVIDER_CATALOG_FILE: new URL("providers.json", import.meta.url).pathname
},
stdio: ["ignore", "pipe", "pipe"]
});
let logs = "";
service.stdout.on("data", (chunk) => { logs += chunk; });
service.stderr.on("data", (chunk) => { logs += chunk; });
function request(pathname, {method = "GET", body} = {}) {
return fetch(`http://127.0.0.1:${port}${pathname}`, {
method,
headers: {
"Authorization": "Bearer integration-service-token",
"Accept": "application/json",
...(body ? {"Content-Type": "application/json"} : {}),
"X-Key-Vault-Actor-Issuer": "https://auth.xiteng.site",
"X-Key-Vault-Actor-Sub": "integration-user",
"X-Key-Vault-Actor-Username": "integration"
},
body: body ? JSON.stringify(body) : undefined
});
}
try {
let ready = false;
for (let attempt = 0; attempt < 50; attempt += 1) {
try {
if ((await fetch(`http://127.0.0.1:${port}/healthz`)).ok) {
ready = true;
break;
}
} catch {}
await new Promise((resolve) => setTimeout(resolve, 50));
}
assert.equal(ready, true, logs || "Key Vault did not start");
const providersResponse = await request("/v1/providers");
assert.equal(providersResponse.status, 200);
const providers = await providersResponse.json();
assert.ok(providers.providers.some((provider) => provider.id === "openai"));
assert.ok(providers.providers.some((provider) => provider.id === "ollama" && provider.credentialState === "local"));
const customResponse = await request("/v1/providers", {
method: "POST",
body: {
id: "integration-provider",
name: "Integration Provider",
api: "openai-completions",
connection: {type: "backend", baseUrl: "https://example.com/v1", proxy: null},
auth: {type: "bearer"},
defaultModel: "test-model",
discovery: {type: "openai-models-list", url: "https://example.com/v1/models"}
}
});
assert.equal(customResponse.status, 200);
const savedCustom = await customResponse.json();
assert.equal(savedCustom.provider.discovery.url, "https://example.com/v1/models");
assert.equal("models" in savedCustom.provider, false);
const createdResponse = await request("/v1/credentials", {
method: "POST",
body: {providerId: "integration-provider", name: "default", secret: {provider: {apiKey: "provider-secret"}}}
});
assert.equal(createdResponse.status, 201);
const resolvedResponse = await request("/v1/resolve", {
method: "POST",
body: {providerId: "integration-provider", credentialName: "default"}
});
assert.equal(resolvedResponse.status, 200);
const resolved = await resolvedResponse.json();
assert.equal(resolved.provider.connection.baseUrl, "https://example.com/v1");
assert.equal(resolved.credential.secret.provider.apiKey, "provider-secret");
const frontendCredential = await request("/v1/credentials", {
method: "POST",
body: {providerId: "ollama", name: "default", secret: "browser-only"}
});
assert.equal(frontendCredential.status, 409);
console.log("Key Vault API tests passed");
} finally {
service.kill("SIGTERM");
await new Promise((resolve) => service.once("exit", resolve));
rmSync(directory, {recursive: true, force: true});
}
+77
View File
@@ -0,0 +1,77 @@
import assert from "node:assert/strict";
import {randomBytes} from "node:crypto";
import {mkdtempSync, rmSync} from "node:fs";
import {tmpdir} from "node:os";
import {join} from "node:path";
import {loadBuiltinProviders, normalizeProvider} from "./providers.mjs";
import {Vault} from "./vault.mjs";
const directory = mkdtempSync(join(tmpdir(), "xiteng-vault-test-"));
const vault = new Vault({
databasePath: join(directory, "vault.db"),
masterKey: randomBytes(32),
builtinProviders: loadBuiltinProviders(new URL("providers.json", import.meta.url))
});
const admin = {issuer: "https://id.xiteng.site", sub: "admin-sub", username: "liooil", admin: true};
const user = {issuer: "https://id.xiteng.site", sub: "user-sub", username: "ziyue", admin: false};
try {
const providers = vault.listProviders(user);
assert.ok(providers.length >= 25);
assert.equal(providers.find((provider) => provider.id === "ollama").connection.type, "frontend");
assert.throws(() => vault.createCredential(user, {
providerId: "ollama",
name: "default",
secret: "must-not-enter-server"
}), /remain in the browser/i);
const custom = normalizeProvider({
id: "team-proxy",
name: "Team Proxy",
api: "openai-completions",
connection: {
type: "backend",
baseUrl: "https://models.example.com/v1",
proxy: {type: "socks5", url: "socks5://proxy.example.com:1080"}
},
auth: {type: "bearer"},
defaultModel: "team-model",
discovery: {type: "openai-models-list", url: "https://models.example.com/v1/catalog"}
});
vault.saveCustomProvider(user, custom);
assert.equal(vault.getProvider(user, "team-proxy").connection.proxy.type, "socks5");
assert.equal(vault.getProvider(user, "team-proxy").discovery.url, "https://models.example.com/v1/catalog");
assert.equal("models" in vault.getProvider(user, "team-proxy"), false);
const credential = vault.createCredential(user, {
providerId: "team-proxy",
name: "default",
secret: {
provider: {apiKey: "secret-one"},
proxy: {username: "proxy-user", password: "proxy-pass"}
}
});
assert.equal(vault.listCredentials(user).length, 1);
assert.equal(vault.listCredentials(admin, {all: true}).length, 1);
assert.equal(vault.verifyCredential(user, credential.id).verified, true);
const resolved = vault.resolve(user, {providerId: "team-proxy", credentialName: "default"});
assert.equal(resolved.provider.id, "team-proxy");
assert.equal(resolved.credential.secret.provider.apiKey, "secret-one");
assert.equal(resolved.credential.secret.proxy.username, "proxy-user");
vault.replaceCredential(user, credential.id, {secret: "secret-two"});
assert.equal(vault.resolve(user, {providerId: "team-proxy"}).credential.secret.provider.apiKey, "secret-two");
assert.throws(() => vault.resolve(admin, {providerId: "team-proxy"}), /provider not found/i);
vault.deleteCredential(user, credential.id);
assert.equal(vault.listCredentials(user).length, 0);
vault.deleteCustomProvider(user, "team-proxy");
assert.equal(vault.getProvider(user, "team-proxy"), null);
assert.ok(vault.listAudit(admin, {all: true}).length >= 7);
console.log("Vault tests passed");
} finally {
vault.close();
rmSync(directory, {recursive: true, force: true});
}
+552
View File
@@ -0,0 +1,552 @@
import {
createCipheriv,
createDecipheriv,
createHash,
randomBytes,
randomUUID
} from "node:crypto";
import {mkdirSync, readFileSync} from "node:fs";
import {DatabaseSync} from "node:sqlite";
import {normalizeProvider} from "./providers.mjs";
function now() {
return new Date().toISOString();
}
function limitedString(value, field, maximum = 120) {
if (typeof value !== "string" || !value.trim()) throw new Error(`${field} is required`);
return value.trim().slice(0, maximum);
}
function secretBuffer(value) {
let normalized;
if (typeof value === "string") {
if (!value) throw new Error("secret is required");
normalized = {provider: {apiKey: value}};
} else if (value && typeof value === "object" && !Array.isArray(value)) {
normalized = value;
} else {
throw new Error("secret is required");
}
const buffer = Buffer.from(JSON.stringify(normalized));
if (buffer.length > 65536) throw new Error("secret is too large");
return buffer;
}
function parseSecret(buffer) {
const text = buffer.toString("utf8");
try {
const parsed = JSON.parse(text);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
} catch {}
return {provider: {apiKey: text}};
}
export function loadKeyFile(path) {
const value = readFileSync(path);
const text = value.toString("utf8").trim();
const decoded = /^[A-Fa-f0-9]{64}$/.test(text) ? Buffer.from(text, "hex") : Buffer.from(text, "base64");
if (decoded.length !== 32) {
throw new Error(`Key file ${path} must contain exactly 32 random bytes encoded as base64 or hex`);
}
return decoded;
}
function encrypt(key, plaintext, associatedData) {
const nonce = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", key, nonce);
cipher.setAAD(Buffer.from(associatedData));
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
return {ciphertext, nonce, tag: cipher.getAuthTag()};
}
function decrypt(key, ciphertext, nonce, tag, associatedData) {
const decipher = createDecipheriv("aes-256-gcm", key, nonce);
decipher.setAAD(Buffer.from(associatedData));
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
}
function credentialAad(record) {
return `credential:v1:${record.id}:${record.owner_issuer}:${record.owner_sub}:${record.provider}`;
}
function wrappedDekAad(record) {
return `wrapped-dek:v1:${record.id}`;
}
function fingerprint(secret) {
return createHash("sha256").update(secret).digest("hex").slice(0, 16);
}
function publicCredential(row) {
return {
id: row.id,
owner: {
issuer: row.owner_issuer,
sub: row.owner_sub,
username: row.owner_username
},
providerId: row.provider,
name: row.name,
fingerprint: row.fingerprint,
createdAt: row.created_at,
updatedAt: row.updated_at,
lastAccessedAt: row.last_used_at
};
}
export class Vault {
constructor({databasePath, masterKey, builtinProviders}) {
this.masterKey = masterKey;
this.builtinProviders = builtinProviders;
mkdirSync(databasePath.slice(0, databasePath.lastIndexOf("/")) || ".", {recursive: true});
this.database = new DatabaseSync(databasePath);
this.database.exec(`
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
PRAGMA busy_timeout = 5000;
CREATE TABLE IF NOT EXISTS vault_credential (
id TEXT PRIMARY KEY,
owner_issuer TEXT NOT NULL,
owner_sub TEXT NOT NULL,
owner_username TEXT NOT NULL,
owner_type TEXT NOT NULL DEFAULT 'user',
provider TEXT NOT NULL,
name TEXT NOT NULL,
ciphertext BLOB NOT NULL,
nonce BLOB NOT NULL,
auth_tag BLOB NOT NULL,
wrapped_dek BLOB NOT NULL,
wrap_nonce BLOB NOT NULL,
wrap_tag BLOB NOT NULL,
key_version INTEGER NOT NULL DEFAULT 1,
fingerprint TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_used_at TEXT,
revoked_at TEXT
);
CREATE INDEX IF NOT EXISTS vault_credential_owner
ON vault_credential(owner_issuer, owner_sub, provider, name);
CREATE TABLE IF NOT EXISTS custom_provider (
id TEXT PRIMARY KEY,
owner_issuer TEXT NOT NULL,
owner_sub TEXT NOT NULL,
owner_username TEXT NOT NULL,
provider_id TEXT NOT NULL,
definition_json TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(owner_issuer, owner_sub, provider_id)
);
CREATE INDEX IF NOT EXISTS custom_provider_owner
ON custom_provider(owner_issuer, owner_sub, provider_id);
CREATE TABLE IF NOT EXISTS vault_audit_event (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actor_issuer TEXT NOT NULL,
actor_sub TEXT NOT NULL,
actor_username TEXT NOT NULL,
action TEXT NOT NULL,
target_type TEXT NOT NULL,
target_id TEXT,
owner_issuer TEXT,
owner_sub TEXT,
result TEXT NOT NULL,
detail TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS vault_audit_owner_time
ON vault_audit_event(owner_issuer, owner_sub, created_at DESC);
DROP TABLE IF EXISTS credential_grant;
UPDATE vault_credential SET status = 'active', revoked_at = NULL;
`);
}
close() {
this.database.close();
}
audit(actor, action, targetType, targetId, owner, result = "success", detail = null) {
this.database.prepare(`
INSERT INTO vault_audit_event (
actor_issuer, actor_sub, actor_username, action, target_type, target_id,
owner_issuer, owner_sub, result, detail, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
actor.issuer,
actor.sub,
actor.username,
action,
targetType,
targetId,
owner?.issuer || null,
owner?.sub || null,
result,
detail,
now()
);
}
ownerFromInput(actor, input) {
return input.owner && actor.admin
? {
issuer: limitedString(input.owner.issuer, "owner.issuer", 300),
sub: limitedString(input.owner.sub, "owner.sub", 200),
username: limitedString(input.owner.username, "owner.username", 80)
}
: {issuer: actor.issuer, sub: actor.sub, username: actor.username};
}
customProviderDefinition(value) {
return normalizeProvider(JSON.parse(value));
}
listCustomProviders(actor) {
return this.database.prepare(`
SELECT * FROM custom_provider
WHERE owner_issuer = ? AND owner_sub = ?
ORDER BY provider_id COLLATE NOCASE
`).all(actor.issuer, actor.sub).map((row) => ({
...this.customProviderDefinition(row.definition_json),
createdAt: row.created_at,
updatedAt: row.updated_at
}));
}
getProviderForOwner(owner, providerId) {
const id = limitedString(providerId, "providerId", 80).toLowerCase();
const custom = this.database.prepare(`
SELECT definition_json FROM custom_provider
WHERE owner_issuer = ? AND owner_sub = ? AND provider_id = ?
`).get(owner.issuer, owner.sub, id);
return custom ? this.customProviderDefinition(custom.definition_json) : this.builtinProviders.get(id) || null;
}
getProvider(actor, providerId) {
return this.getProviderForOwner(actor, providerId);
}
listProviders(actor) {
const effective = new Map([...this.builtinProviders].map(([id, provider]) => [id, {...provider}]));
for (const provider of this.listCustomProviders(actor)) effective.set(provider.id, provider);
const credentials = this.listCredentials(actor);
return [...effective.values()]
.sort((left, right) => left.name.localeCompare(right.name))
.map((provider) => ({
...provider,
credentials: provider.connection.type === "backend"
? credentials.filter((credential) => credential.providerId === provider.id)
: [],
credentialState: provider.connection.type === "frontend"
? "local"
: credentials.some((credential) => credential.providerId === provider.id) ? "configured" : "missing"
}));
}
saveCustomProvider(actor, provider) {
const timestamp = now();
const existing = this.database.prepare(`
SELECT id, created_at FROM custom_provider
WHERE owner_issuer = ? AND owner_sub = ? AND provider_id = ?
`).get(actor.issuer, actor.sub, provider.id);
const id = existing?.id || randomUUID();
this.database.prepare(`
INSERT INTO custom_provider (
id, owner_issuer, owner_sub, owner_username, provider_id, definition_json, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(owner_issuer, owner_sub, provider_id) DO UPDATE SET
owner_username = excluded.owner_username,
definition_json = excluded.definition_json,
updated_at = excluded.updated_at
`).run(
id,
actor.issuer,
actor.sub,
actor.username,
provider.id,
JSON.stringify({...provider, builtin: false}),
existing?.created_at || timestamp,
timestamp
);
this.audit(actor, existing ? "provider.update" : "provider.create", "provider", provider.id, actor, "success", provider.connection.type);
return this.getProvider(actor, provider.id);
}
deleteCustomProvider(actor, providerId) {
const id = limitedString(providerId, "providerId", 80).toLowerCase();
const result = this.database.prepare(`
DELETE FROM custom_provider WHERE owner_issuer = ? AND owner_sub = ? AND provider_id = ?
`).run(actor.issuer, actor.sub, id);
if (!result.changes) {
const error = new Error("Custom provider not found");
error.statusCode = 404;
throw error;
}
this.audit(actor, "provider.delete", "provider", id, actor);
return {deleted: true, id};
}
listCredentials(actor, {all = false} = {}) {
const rows = all && actor.admin
? this.database.prepare(`SELECT * FROM vault_credential ORDER BY owner_username, provider, name`).all()
: this.database.prepare(`
SELECT * FROM vault_credential
WHERE owner_issuer = ? AND owner_sub = ?
ORDER BY provider, name
`).all(actor.issuer, actor.sub);
return rows.map(publicCredential);
}
getCredential(id) {
return this.database.prepare("SELECT * FROM vault_credential WHERE id = ?").get(id);
}
assertAccess(actor, row) {
if (!row || (!actor.admin && (row.owner_issuer !== actor.issuer || row.owner_sub !== actor.sub))) {
const error = new Error("Credential not found");
error.statusCode = 404;
throw error;
}
}
createCredential(actor, input) {
const owner = this.ownerFromInput(actor, input);
const providerId = limitedString(input.providerId, "providerId", 80).toLowerCase();
const provider = this.getProviderForOwner(owner, providerId);
if (!provider) {
const error = new Error("Provider not found");
error.statusCode = 404;
throw error;
}
if (provider.connection.type !== "backend") {
const error = new Error("Frontend credentials must remain in the browser");
error.statusCode = 409;
throw error;
}
const name = limitedString(input.name || "default", "name", 120);
const duplicate = this.database.prepare(`
SELECT id FROM vault_credential WHERE owner_issuer = ? AND owner_sub = ? AND provider = ? AND name = ?
`).get(owner.issuer, owner.sub, providerId, name);
if (duplicate) {
const error = new Error("Credential already exists");
error.statusCode = 409;
throw error;
}
const id = randomUUID();
const secret = secretBuffer(input.secret);
const record = {id, owner_issuer: owner.issuer, owner_sub: owner.sub, provider: providerId};
const dek = randomBytes(32);
const encryptedSecret = encrypt(dek, secret, credentialAad(record));
const encryptedDek = encrypt(this.masterKey, dek, wrappedDekAad(record));
const timestamp = now();
this.database.prepare(`
INSERT INTO vault_credential (
id, owner_issuer, owner_sub, owner_username, owner_type, provider, name,
ciphertext, nonce, auth_tag, wrapped_dek, wrap_nonce, wrap_tag,
fingerprint, status, created_at, updated_at
) VALUES (?, ?, ?, ?, 'user', ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?)
`).run(
id, owner.issuer, owner.sub, owner.username, providerId, name,
encryptedSecret.ciphertext, encryptedSecret.nonce, encryptedSecret.tag,
encryptedDek.ciphertext, encryptedDek.nonce, encryptedDek.tag,
fingerprint(secret), timestamp, timestamp
);
secret.fill(0);
dek.fill(0);
this.audit(actor, "credential.create", "credential", id, owner, "success", providerId);
return publicCredential(this.getCredential(id));
}
decryptCredential(row) {
const dek = decrypt(this.masterKey, row.wrapped_dek, row.wrap_nonce, row.wrap_tag, wrappedDekAad(row));
try {
return decrypt(dek, row.ciphertext, row.nonce, row.auth_tag, credentialAad(row));
} finally {
dek.fill(0);
}
}
replaceCredential(actor, id, input) {
const row = this.getCredential(id);
this.assertAccess(actor, row);
const secret = secretBuffer(input.secret);
const dek = randomBytes(32);
const encryptedSecret = encrypt(dek, secret, credentialAad(row));
const encryptedDek = encrypt(this.masterKey, dek, wrappedDekAad(row));
const timestamp = now();
this.database.prepare(`
UPDATE vault_credential
SET ciphertext = ?, nonce = ?, auth_tag = ?, wrapped_dek = ?, wrap_nonce = ?, wrap_tag = ?,
fingerprint = ?, updated_at = ?
WHERE id = ?
`).run(
encryptedSecret.ciphertext, encryptedSecret.nonce, encryptedSecret.tag,
encryptedDek.ciphertext, encryptedDek.nonce, encryptedDek.tag,
fingerprint(secret), timestamp, id
);
secret.fill(0);
dek.fill(0);
this.audit(actor, "credential.replace", "credential", id, {issuer: row.owner_issuer, sub: row.owner_sub});
return publicCredential(this.getCredential(id));
}
verifyCredential(actor, id) {
const row = this.getCredential(id);
this.assertAccess(actor, row);
const plaintext = this.decryptCredential(row);
const verified = fingerprint(plaintext) === row.fingerprint;
plaintext.fill(0);
this.audit(actor, "credential.verify", "credential", id, {issuer: row.owner_issuer, sub: row.owner_sub}, verified ? "success" : "failure");
return {verified, fingerprint: row.fingerprint};
}
deleteCredential(actor, id) {
const row = this.getCredential(id);
this.assertAccess(actor, row);
this.database.prepare("DELETE FROM vault_credential WHERE id = ?").run(id);
this.audit(actor, "credential.delete", "credential", id, {issuer: row.owner_issuer, sub: row.owner_sub});
return {deleted: true, id};
}
reassignCredential(actor, id, ownerInput) {
if (!actor.admin) {
const error = new Error("Administrator required");
error.statusCode = 403;
throw error;
}
const row = this.getCredential(id);
this.assertAccess(actor, row);
const owner = {
issuer: limitedString(ownerInput.issuer, "owner.issuer", 300),
sub: limitedString(ownerInput.sub, "owner.sub", 200),
username: limitedString(ownerInput.username, "owner.username", 80)
};
const provider = this.getProviderForOwner(owner, row.provider);
if (!provider || provider.connection.type !== "backend") {
const error = new Error("Target owner has no matching Backend Provider");
error.statusCode = 409;
throw error;
}
const plaintext = this.decryptCredential(row);
const next = {...row, owner_issuer: owner.issuer, owner_sub: owner.sub};
const dek = randomBytes(32);
const encryptedSecret = encrypt(dek, plaintext, credentialAad(next));
const encryptedDek = encrypt(this.masterKey, dek, wrappedDekAad(next));
this.database.prepare(`
UPDATE vault_credential
SET owner_issuer = ?, owner_sub = ?, owner_username = ?,
ciphertext = ?, nonce = ?, auth_tag = ?, wrapped_dek = ?, wrap_nonce = ?, wrap_tag = ?, updated_at = ?
WHERE id = ?
`).run(
owner.issuer, owner.sub, owner.username,
encryptedSecret.ciphertext, encryptedSecret.nonce, encryptedSecret.tag,
encryptedDek.ciphertext, encryptedDek.nonce, encryptedDek.tag, now(), id
);
plaintext.fill(0);
dek.fill(0);
this.audit(actor, "credential.reassign", "credential", id, owner, "success", `previous owner ${row.owner_username}`);
return publicCredential(this.getCredential(id));
}
resolve(actor, input) {
const providerId = limitedString(input.providerId, "providerId", 80).toLowerCase();
const provider = this.getProvider(actor, providerId);
if (!provider) {
const error = new Error("Provider not found");
error.statusCode = 404;
throw error;
}
if (provider.connection.type !== "backend") {
const error = new Error("Frontend credentials are stored in the browser");
error.statusCode = 409;
throw error;
}
const name = limitedString(input.credentialName || "default", "credentialName", 120);
const row = this.database.prepare(`
SELECT * FROM vault_credential
WHERE owner_issuer = ? AND owner_sub = ? AND provider = ? AND name = ?
`).get(actor.issuer, actor.sub, providerId, name);
if (!row) {
const error = new Error("Credential not found");
error.statusCode = 404;
throw error;
}
const plaintext = this.decryptCredential(row);
const secret = parseSecret(plaintext);
plaintext.fill(0);
this.database.prepare("UPDATE vault_credential SET last_used_at = ? WHERE id = ?").run(now(), row.id);
this.audit(actor, "credential.resolve", "credential", row.id, actor, "success", providerId);
return {provider, credential: {id: row.id, name: row.name, secret}};
}
listAudit(actor, {all = false, limit = 100} = {}) {
const boundedLimit = Math.max(1, Math.min(500, Number.parseInt(limit, 10) || 100));
const rows = all && actor.admin
? this.database.prepare("SELECT * FROM vault_audit_event ORDER BY created_at DESC LIMIT ?").all(boundedLimit)
: this.database.prepare(`
SELECT * FROM vault_audit_event
WHERE owner_issuer = ? AND owner_sub = ?
ORDER BY created_at DESC LIMIT ?
`).all(actor.issuer, actor.sub, boundedLimit);
return rows.map((row) => ({
id: row.id,
actorUsername: row.actor_username,
action: row.action,
targetType: row.target_type,
targetId: row.target_id,
result: row.result,
detail: row.detail,
createdAt: row.created_at
}));
}
rotateMasterKey(actor, newMasterKey) {
if (!actor.admin) throw new Error("Administrator required");
const rows = this.database.prepare("SELECT * FROM vault_credential").all();
this.database.exec("BEGIN IMMEDIATE");
try {
const update = this.database.prepare(`
UPDATE vault_credential
SET wrapped_dek = ?, wrap_nonce = ?, wrap_tag = ?, key_version = key_version + 1, updated_at = ?
WHERE id = ?
`);
for (const row of rows) {
const dek = decrypt(this.masterKey, row.wrapped_dek, row.wrap_nonce, row.wrap_tag, wrappedDekAad(row));
const wrapped = encrypt(newMasterKey, dek, wrappedDekAad(row));
dek.fill(0);
update.run(wrapped.ciphertext, wrapped.nonce, wrapped.tag, now(), row.id);
}
this.database.exec("COMMIT");
} catch (error) {
this.database.exec("ROLLBACK");
throw error;
}
this.masterKey = newMasterKey;
this.audit(actor, "master-key.rotate", "vault", null, null, "success", `${rows.length} DEKs rewrapped`);
return {rewrapped: rows.length};
}
backup(destination) {
const escaped = destination.replaceAll("'", "''");
this.database.exec(`VACUUM INTO '${escaped}'`);
return {destination};
}
}
export function breakGlassActor() {
return {
issuer: "urn:xiteng:break-glass",
sub: "local-emergency-script",
username: "liooil",
admin: true
};
}