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