feat: rebuild xiteng.site homelab platform
This commit is contained in:
@@ -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