import { eq } from 'drizzle-orm'; import { db } from 'officerdb/db'; import { encryptSecret, decryptSecret } from 'officerdb/crypto'; import { vaultConfig, vaultTokens, vaultUnlockKeys } from './schema'; // Vault store access. Callers deal in PLAINTEXT — encryption to/from at-rest ciphertext happens here, so // the token broker / proxy never touch the crypto directly. The key purpose is `vault`, minted on first // use by the secret store (see docs/secret-store.md). // ── Instance configuration (server-wide, one row) ── export type VaultConfig = { url: string | null; enabled: boolean; updatedAt: Date | null }; const EMPTY_CONFIG: VaultConfig = { url: null, enabled: true, updatedAt: null }; /** * Where this install's Vaultwarden is, or `url: null` when the owner has not pointed it anywhere yet. * * Never throws and never creates the row. A missing row and an unset URL are the same answer to the only * question anyone asks here — "is there somewhere to forward to" — and an install that has not been * configured is an ordinary state rather than an error. */ export async function getVaultConfig(): Promise { const [row] = await db.select().from(vaultConfig).where(eq(vaultConfig.id, 1)); if (!row) return EMPTY_CONFIG; return { url: row.url, enabled: row.enabled, updatedAt: row.updatedAt }; } /** Trailing slash removed on write, so `${url}/identity/...` can never double the separator. */ const normaliseUrl = (raw: string): string => raw.trim().replace(/\/+$/, ''); /** * Point this install at a Vaultwarden, or change which one. Upsert on the pinned id, so the first write * creates the singleton and every later one replaces it. * * The URL is stored as given (normalised), NOT probed here. Reachability is a separate question with a * separate answer — see the `/config/health` route — and a setter that refused an unreachable URL would * make configuring a vault that is temporarily down impossible. */ export async function setVaultConfig(patch: { url?: string | null; enabled?: boolean }): Promise { const values = { id: 1, ...(patch.url !== undefined ? { url: patch.url === null ? null : normaliseUrl(patch.url) } : {}), ...(patch.enabled !== undefined ? { enabled: patch.enabled } : {}), updatedAt: new Date(), }; const [row] = await db .insert(vaultConfig) .values(values) .onConflictDoUpdate({ target: vaultConfig.id, set: values }) .returning(); return row ? { url: row.url, enabled: row.enabled, updatedAt: row.updatedAt } : EMPTY_CONFIG; } // ── Brokered Vaultwarden tokens ── export type VaultTokenSet = { accessToken: string; refreshToken: string; expiresAt: Date | null; deviceIdentifier: string | null; clientId: string | null; }; /** The owner's brokered Vaultwarden token set (decrypted), or null if none is stored. */ export async function getVaultTokens(userId: number): Promise { const [row] = await db.select().from(vaultTokens).where(eq(vaultTokens.userId, userId)); if (!row) return null; return { accessToken: decryptSecret('vault', row.accessToken), refreshToken: decryptSecret('vault', row.refreshToken), expiresAt: row.expiresAt, deviceIdentifier: row.deviceIdentifier, clientId: row.clientId, }; } /** Upsert the owner's token set (one row per account). Secrets are encrypted before write. */ export async function setVaultTokens(userId: number, t: VaultTokenSet): Promise { const values = { userId, accessToken: encryptSecret('vault', t.accessToken), refreshToken: encryptSecret('vault', t.refreshToken), expiresAt: t.expiresAt, deviceIdentifier: t.deviceIdentifier, clientId: t.clientId, updatedAt: new Date(), }; await db .insert(vaultTokens) .values(values) .onConflictDoUpdate({ target: vaultTokens.userId, set: { accessToken: values.accessToken, refreshToken: values.refreshToken, expiresAt: values.expiresAt, deviceIdentifier: values.deviceIdentifier, clientId: values.clientId, updatedAt: values.updatedAt, }, }); } /** Update only the token pair + expiry after a refresh (keeps device/client). */ export async function updateVaultAccess( userId: number, accessToken: string, refreshToken: string, expiresAt: Date | null, ): Promise { await db .update(vaultTokens) .set({ accessToken: encryptSecret('vault', accessToken), refreshToken: encryptSecret('vault', refreshToken), expiresAt, updatedAt: new Date(), }) .where(eq(vaultTokens.userId, userId)); } /** Drop the token set (platform logout / distress / panic). */ export async function clearVaultTokens(userId: number): Promise { await db.delete(vaultTokens).where(eq(vaultTokens.userId, userId)); } // ── The Officer-app unlock protector key ── /** The owner's stored protector key (decrypted), or null. Officer-app unlock path only. */ export async function getVaultUnlockKey(userId: number): Promise { const [row] = await db.select().from(vaultUnlockKeys).where(eq(vaultUnlockKeys.userId, userId)); return row ? decryptSecret('vault', row.wrappedKey) : null; } /** Store/replace the protector key (encrypted). Set once at setup; persists across normal logout. */ export async function setVaultUnlockKey(userId: number, wrappedKey: string): Promise { const values = { userId, wrappedKey: encryptSecret('vault', wrappedKey), updatedAt: new Date() }; await db .insert(vaultUnlockKeys) .values(values) .onConflictDoUpdate({ target: vaultUnlockKeys.userId, set: { wrappedKey: values.wrappedKey, updatedAt: values.updatedAt }, }); } /** Wipe the protector key (distress / panic → forces a one-time master-password re-setup). */ export async function clearVaultUnlockKey(userId: number): Promise { await db.delete(vaultUnlockKeys).where(eq(vaultUnlockKeys.userId, userId)); }