vault: encrypted at-rest storage for brokered tokens + unlock key

Foundation for the platform-brokered vault auth (VAULT_AUTH_SPEC.md). Two
owner-keyed tables: vault_tokens (the brokered Vaultwarden access/refresh set)
and vault_unlock_keys (the Officer-app protector key). All secret columns are
AES-256-GCM encrypted via a VAULT_STORE_KEY-derived key (crypto.ts, lazy-loaded
so the platform still boots without it); queries encrypt/decrypt transparently.

Migration SQL is applied via db:push/psql (schema is source of truth); the
generated files are left out to avoid the shared-journal coupling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 02:33:28 +00:00
co-authored by Claude Opus 4.8
parent 4d6975934a
commit 192337cd1b
5 changed files with 160 additions and 0 deletions
+40
View File
@@ -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');
}
+9
View File
@@ -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';
@@ -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<VaultTokenSet | null> {
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<void> {
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<void> {
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<string | null> {
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<void> {
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<void> {
await db.delete(vaultUnlockKeys).where(eq(vaultUnlockKeys.userId, userId));
}
@@ -7,3 +7,4 @@ export * from './email';
export * from './pipeline-jobs';
export * from './chat-events';
export * from './music';
export * from './vault';
@@ -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(),
});