feat: rebuild xiteng.site homelab platform
This commit is contained in:
@@ -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.
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
@@ -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});
|
||||
}
|
||||
@@ -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});
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user