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,
|
PlaylistSummary,
|
||||||
Playlist,
|
Playlist,
|
||||||
} from './queries/music';
|
} from './queries/music';
|
||||||
|
export {
|
||||||
|
getVaultTokens,
|
||||||
|
setVaultTokens,
|
||||||
|
clearVaultTokens,
|
||||||
|
getVaultUnlockKey,
|
||||||
|
setVaultUnlockKey,
|
||||||
|
clearVaultUnlockKey,
|
||||||
|
} from './queries/vault';
|
||||||
|
export type { VaultTokenSet } from './queries/vault';
|
||||||
|
|
||||||
export { db } from './db';
|
export { db } from './db';
|
||||||
export * as schema from './schema';
|
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 './pipeline-jobs';
|
||||||
export * from './chat-events';
|
export * from './chat-events';
|
||||||
export * from './music';
|
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