Files
homelab/ai-gateway/vault.mjs

553 lines
20 KiB
JavaScript

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