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);