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:
@@ -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');
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
Reference in New Issue
Block a user