diff --git a/src/databases/officer_db/src/crypto.ts b/src/databases/officer_db/src/crypto.ts new file mode 100644 index 00000000..71fcfd2b --- /dev/null +++ b/src/databases/officer_db/src/crypto.ts @@ -0,0 +1,40 @@ +import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'; + +// AES-256-GCM at-rest encryption for vault secrets (the brokered Vaultwarden token set + the Officer-app +// protector key). The whole point of the vault store is that a DB dump must not hand over the keys to the +// vault, so these columns are never stored plaintext. +// +// Key = SHA-256(VAULT_STORE_KEY) so any sufficiently strong secret works (mirrors the JWT_SECRET style). +// Format = base64(iv[12] | authTag[16] | ciphertext). The key is read LAZILY so the platform still boots +// without a vault configured — vault storage ops then throw a clear error instead of crashing startup. + +let cachedKey: Buffer | null = null; +function key(): Buffer { + if (cachedKey) return cachedKey; + const secret = process.env.VAULT_STORE_KEY; + if (!secret || secret.length < 16) { + throw new Error('VAULT_STORE_KEY must be set (>=16 chars) to store vault secrets'); + } + cachedKey = createHash('sha256').update(secret).digest(); + return cachedKey; +} + +/** Encrypt a UTF-8 secret for at-rest storage → base64(iv|tag|ciphertext). */ +export function encryptSecret(plaintext: string): string { + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', key(), iv); + const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + return Buffer.concat([iv, tag, ct]).toString('base64'); +} + +/** Decrypt a value produced by encryptSecret. Throws if the ciphertext/tag/key don't verify. */ +export function decryptSecret(blob: string): string { + const buf = Buffer.from(blob, 'base64'); + const iv = buf.subarray(0, 12); + const tag = buf.subarray(12, 28); + const ct = buf.subarray(28); + const decipher = createDecipheriv('aes-256-gcm', key(), iv); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8'); +} diff --git a/src/databases/officer_db/src/index.ts b/src/databases/officer_db/src/index.ts index 1e3b8e97..61e32c84 100644 --- a/src/databases/officer_db/src/index.ts +++ b/src/databases/officer_db/src/index.ts @@ -102,6 +102,15 @@ export type { PlaylistSummary, Playlist, } from './queries/music'; +export { + getVaultTokens, + setVaultTokens, + clearVaultTokens, + getVaultUnlockKey, + setVaultUnlockKey, + clearVaultUnlockKey, +} from './queries/vault'; +export type { VaultTokenSet } from './queries/vault'; export { db } from './db'; export * as schema from './schema'; diff --git a/src/databases/officer_db/src/queries/vault.ts b/src/databases/officer_db/src/queries/vault.ts new file mode 100644 index 00000000..4c54681c --- /dev/null +++ b/src/databases/officer_db/src/queries/vault.ts @@ -0,0 +1,79 @@ +import { eq } from 'drizzle-orm'; +import { db } from '../db'; +import { vaultTokens, vaultUnlockKeys } from '../schema'; +import { encryptSecret, decryptSecret } from '../crypto'; + +// 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. See ../crypto.ts and ../schema/vault.ts. + +export type VaultTokenSet = { + accessToken: string; + refreshToken: string; + expiresAt: Date | null; + deviceIdentifier: 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(row.accessToken), + refreshToken: decryptSecret(row.refreshToken), + expiresAt: row.expiresAt, + deviceIdentifier: row.deviceIdentifier, + }; +} + +/** 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(t.accessToken), + refreshToken: encryptSecret(t.refreshToken), + expiresAt: t.expiresAt, + deviceIdentifier: t.deviceIdentifier, + 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, + updatedAt: values.updatedAt, + }, + }); +} + +/** 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 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(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(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)); +} diff --git a/src/databases/officer_db/src/schema/index.ts b/src/databases/officer_db/src/schema/index.ts index 640b86ca..8950d94d 100644 --- a/src/databases/officer_db/src/schema/index.ts +++ b/src/databases/officer_db/src/schema/index.ts @@ -7,3 +7,4 @@ export * from './email'; export * from './pipeline-jobs'; export * from './chat-events'; export * from './music'; +export * from './vault'; diff --git a/src/databases/officer_db/src/schema/vault.ts b/src/databases/officer_db/src/schema/vault.ts new file mode 100644 index 00000000..5bfe9a13 --- /dev/null +++ b/src/databases/officer_db/src/schema/vault.ts @@ -0,0 +1,31 @@ +import { pgTable, integer, text, timestamp } from 'drizzle-orm/pg-core'; +import { users } from './auth'; + +// Officer Vault server-side state (see VAULT_AUTH_SPEC.md). The device never holds a Vaultwarden token; +// the platform brokers it, stores it here tied to the owner account, and injects it on proxied /api/vault +// requests. All secret columns are AES-256-GCM encrypted at rest (see ../crypto.ts). + +// The brokered Vaultwarden token set. One row per owner account (single active vault session). Dropped on +// platform logout / distress / panic. +export const vaultTokens = pgTable('vault_tokens', { + userId: integer('user_id') + .primaryKey() + .references(() => users.id, { onDelete: 'cascade' }), + accessToken: text('access_token').notNull(), // encrypted + refreshToken: text('refresh_token').notNull(), // encrypted + expiresAt: timestamp('expires_at', { withTimezone: true }), + deviceIdentifier: text('device_identifier'), // plaintext; the last device that brokered a login + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}); + +// The platform-held "protector key" for the Officer-app unlock path: it unwraps the on-device wrapped user +// key, so a valid platform session opens the vault without the master password. Persists across normal +// logout (frictionless re-login); wiped on distress/panic (forces a one-time master-password re-setup). +// The zero-knowledge OffVault path never writes here (its protector stays in the Secure Enclave). +export const vaultUnlockKeys = pgTable('vault_unlock_keys', { + userId: integer('user_id') + .primaryKey() + .references(() => users.id, { onDelete: 'cascade' }), + wrappedKey: text('wrapped_key').notNull(), // encrypted (the protector key) + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +});