From ccc86cca6d34e2d2cb143eff028fae52108f6220 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Wed, 29 Jul 2026 02:40:21 +0000 Subject: [PATCH] vault: session-gated auth-injecting proxy + token broker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the platform half of VAULT_AUTH_SPEC.md. /api/vault is now gated on an owner platform session (userMiddleware, no bodyParser → streaming preserved) and origin-scoped as before; the device holds no Vaultwarden token. - POST /session/login {email, authHash, kdf, device*} → broker calls Vaultwarden /identity/connect/token via the sidecar, stores the encrypted token set tied to the owner, and returns {protectedUserKey, privateKey, kdf} (ciphertext to us). - GET/PUT /unlock-key → store/release the Officer-app protector key (owner only). - Catch-all proxy swaps the incoming platform JWT for the stored Vaultwarden access token, proactively refreshes near expiry, and retries once on a 401 for replayable requests. Bodies are never parsed. client_id column added to vault_tokens (needed to refresh). Broker error text is read across Vaultwarden's message/errorModel/error fields. Co-Authored-By: Claude Opus 4.8 --- src/databases/officer_db/src/index.ts | 1 + src/databases/officer_db/src/queries/vault.ts | 22 +++ src/databases/officer_db/src/schema/vault.ts | 1 + src/servers/api/vault/broker.ts | 79 ++++++++ src/servers/api/vault/router.ts | 176 ++++++++++++++++-- 5 files changed, 261 insertions(+), 18 deletions(-) create mode 100644 src/servers/api/vault/broker.ts diff --git a/src/databases/officer_db/src/index.ts b/src/databases/officer_db/src/index.ts index 61e32c84..a71729a4 100644 --- a/src/databases/officer_db/src/index.ts +++ b/src/databases/officer_db/src/index.ts @@ -105,6 +105,7 @@ export type { export { getVaultTokens, setVaultTokens, + updateVaultAccess, clearVaultTokens, getVaultUnlockKey, setVaultUnlockKey, diff --git a/src/databases/officer_db/src/queries/vault.ts b/src/databases/officer_db/src/queries/vault.ts index 4c54681c..a6d19399 100644 --- a/src/databases/officer_db/src/queries/vault.ts +++ b/src/databases/officer_db/src/queries/vault.ts @@ -11,6 +11,7 @@ export type VaultTokenSet = { 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. */ @@ -22,6 +23,7 @@ export async function getVaultTokens(userId: number): Promise { + await db + .update(vaultTokens) + .set({ + accessToken: encryptSecret(accessToken), + refreshToken: encryptSecret(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)); diff --git a/src/databases/officer_db/src/schema/vault.ts b/src/databases/officer_db/src/schema/vault.ts index 5bfe9a13..1012656e 100644 --- a/src/databases/officer_db/src/schema/vault.ts +++ b/src/databases/officer_db/src/schema/vault.ts @@ -15,6 +15,7 @@ export const vaultTokens = pgTable('vault_tokens', { refreshToken: text('refresh_token').notNull(), // encrypted expiresAt: timestamp('expires_at', { withTimezone: true }), deviceIdentifier: text('device_identifier'), // plaintext; the last device that brokered a login + clientId: text('client_id'), // plaintext; the connect/token client_id, needed to refresh updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }); diff --git a/src/servers/api/vault/broker.ts b/src/servers/api/vault/broker.ts new file mode 100644 index 00000000..f1042362 --- /dev/null +++ b/src/servers/api/vault/broker.ts @@ -0,0 +1,79 @@ +import { getVaultServerUrl } from './sidecar-server'; + +// Vaultwarden token broker. The platform (not the device) obtains and refreshes the Vaultwarden token via +// the sidecar's `/identity/connect/token`. We deal in the OAuth-style form grants only; the master +// password never appears here — the client sends a pre-derived `authHash` (Bitwarden's master password +// hash), exactly what Vaultwarden itself checks. + +export type ConnectResult = { ok: true; data: Record } | { ok: false; status: number; error: string }; + +async function postConnectToken(form: URLSearchParams): Promise { + const base = getVaultServerUrl(); + if (!base) return { ok: false, status: 503, error: 'vault sidecar unavailable' }; + let res: Response; + try { + res = await fetch(`${base}/identity/connect/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: form.toString(), + signal: AbortSignal.timeout(30_000), + }); + } catch { + return { ok: false, status: 502, error: 'vault upstream unreachable' }; + } + const data = (await res.json().catch(() => ({}))) as Record; + if (!res.ok) { + // Vaultwarden puts the human message in different places (error_description/error are often empty on a + // bad login; the real text is in `message` or `errorModel.message`). Check them all. + const errorModel = data.errorModel as Record | undefined; + const candidates = [data.error_description, errorModel?.message, data.message, data.error]; + const desc = candidates.find((c): c is string => typeof c === 'string' && c.length > 0) ?? 'vault login failed'; + return { ok: false, status: res.status, error: desc }; + } + return { ok: true, data }; +} + +export type PasswordGrantInput = { + email: string; + authHash: string; + clientId: string; + deviceIdentifier: string; + deviceType: string; + deviceName: string; +}; + +/** Owner login: exchange the client-derived auth hash for a Vaultwarden token set. */ +export function passwordGrant(p: PasswordGrantInput): Promise { + const form = new URLSearchParams(); + form.set('grant_type', 'password'); + form.set('username', p.email); + form.set('password', p.authHash); + form.set('scope', 'api offline_access'); // offline_access → a refresh token comes back + form.set('client_id', p.clientId); + form.set('deviceIdentifier', p.deviceIdentifier); + form.set('deviceType', p.deviceType); + form.set('deviceName', p.deviceName); + return postConnectToken(form); +} + +/** Renew the access token from the stored refresh token (no password needed). */ +export function refreshGrant(refreshToken: string, clientId: string): Promise { + const form = new URLSearchParams(); + form.set('grant_type', 'refresh_token'); + form.set('refresh_token', refreshToken); + form.set('client_id', clientId); + return postConnectToken(form); +} + +// Vaultwarden mixes PascalCase and camelCase field names across versions — read a field case-insensitively. +export function pick(data: Record, name: string): unknown { + const lower = name.charAt(0).toLowerCase() + name.slice(1); + const upper = name.charAt(0).toUpperCase() + name.slice(1); + return data[name] ?? data[lower] ?? data[upper]; +} + +/** Compute an absolute expiry from a token response's `expires_in` (seconds), if present. */ +export function expiryFrom(data: Record, now: number): Date | null { + const secs = data.expires_in; + return typeof secs === 'number' && Number.isFinite(secs) ? new Date(now + secs * 1000) : null; +} diff --git a/src/servers/api/vault/router.ts b/src/servers/api/vault/router.ts index 323cf25d..e47c914f 100644 --- a/src/servers/api/vault/router.ts +++ b/src/servers/api/vault/router.ts @@ -1,41 +1,171 @@ +import type { MiddlewareHandler } from 'hono'; import { createRouter } from '../../create-router'; +import { originMiddleware, userMiddleware } from '../../_middlewares'; +import { isSuperAdmin } from '../../super-admin'; +import * as errors from '../../custom-errors'; import { getVaultServerUrl } from './sidecar-server'; import { stripHopByHop, redactPath } from './proxy-util'; +import { passwordGrant, refreshGrant, pick, expiryFrom, type PasswordGrantInput } from './broker'; +import { + getVaultTokens, + setVaultTokens, + updateVaultAccess, + getVaultUnlockKey, + setVaultUnlockKey, + type VaultTokenSet, +} from 'officerdb'; -// Thin, origin-gated forwarder for /api/vault/* → the officer-vault sidecar, which OWNS the transparent -// reverse-proxy to Vaultwarden (its URL, paths, and notifications WebSocket all live there). The platform's -// only jobs here are AUTH/ORIGIN (via the global originScopeMiddleware — OFFICER_VAULT_ORIGIN → /api/vault) -// and faithful FORWARDING. We never touch bodies: method/path/query/headers/status and both body streams -// pass through verbatim. -// -// Mounted TOP-LEVEL at /api/vault (see hono.ts) — NOT under the protected router: the Bitwarden client -// carries its own bearer token, not a platform session JWT, so userMiddleware would 401 it. The -// notifications WebSocket is upgraded at the serve level (server.tsx) and likewise forwarded to the sidecar. +// Platform side of Officer Vault (VAULT_AUTH_SPEC.md). The device holds NO Vaultwarden token; a valid +// platform session authorizes vault access. This router: +// • gates every /api/vault/* on an owner platform session (userMiddleware, no bodyParser → streaming), +// • serves the native broker/unlock-key endpoints, +// • proxies the rest to the officer-vault sidecar, swapping the incoming platform JWT for the stored +// Vaultwarden token. Bodies are never parsed/decrypted; only the Authorization header is rewritten. +// The origin scoping (OFFICER_VAULT_ORIGIN → /api/vault) is enforced globally by originScopeMiddleware. export const vaultRouter = createRouter(); const PREFIX = '/api/vault'; +const REFRESH_SKEW_MS = 60_000; // refresh a bit before expiry to avoid racing a 401 +// Owner-only. The global backstop already blocks non-owner tokens from /api/vault; this is explicit +// defense-in-depth and gives a clear error. +const ownerGate: MiddlewareHandler = async (ctx, next) => { + if (!(await isSuperAdmin(ctx.get('user')))) throw errors.FORBIDDEN('Vault is owner-only'); + return next(); +}; + +vaultRouter.use(originMiddleware); // set ctx 'origin' for userMiddleware's origin check +vaultRouter.use(userMiddleware); // valid platform session (Authorization: Bearer or ?token=) +vaultRouter.use(ownerGate); + +// Renew the access token from the stored refresh token; persist and return the new access token, or null. +async function refreshAccess(userId: number, ts: VaultTokenSet): Promise { + const r = await refreshGrant(ts.refreshToken, ts.clientId ?? 'mobile'); + if (!r.ok) return null; + const access = pick(r.data, 'access_token'); + const refresh = pick(r.data, 'refresh_token'); + if (typeof access !== 'string') return null; + await updateVaultAccess( + userId, + access, + typeof refresh === 'string' ? refresh : ts.refreshToken, + expiryFrom(r.data, Date.now()), + ); + return access; +} + +// ── Native endpoints (registered before the catch-all) ── + +// Reachability probe (owner-gated): forwards to the sidecar's /_health, which probes Vaultwarden's /alive. +vaultRouter.get('/_health', async (ctx) => { + const base = getVaultServerUrl(); + if (!base) return ctx.json({ ok: false, error: 'vault sidecar unavailable' }, 503); + try { + const r = await fetch(`${base}/_health`, { signal: AbortSignal.timeout(5000) }); + const body = await r.json().catch(() => ({ ok: r.ok })); + return Response.json(body, { status: r.status }); + } catch { + return ctx.json({ ok: false, error: 'vault sidecar unreachable' }, 502); + } +}); + +// Token broker — exchange the client-derived auth hash for a Vaultwarden token set (stored server-side), +// and return the encrypted user-key material the client needs to unlock on-device. +vaultRouter.post('/session/login', async (ctx) => { + const user = ctx.get('user'); + const b = (await ctx.req.json().catch(() => ({}))) as Record; + const str = (v: unknown) => (typeof v === 'string' ? v : ''); + const email = str(b.email); + const authHash = str(b.authHash); + if (!email || !authHash) return ctx.json({ ok: false, error: 'email and authHash required' }, 400); + + const clientId = str(b.clientId) || 'mobile'; + const input: PasswordGrantInput = { + email, + authHash, + clientId, + deviceIdentifier: str(b.deviceIdentifier), + deviceType: b.deviceType != null ? String(b.deviceType) : '', + deviceName: str(b.deviceName), + }; + const result = await passwordGrant(input); + if (!result.ok) return Response.json({ ok: false, error: result.error }, { status: result.status }); + + const d = result.data; + const access = pick(d, 'access_token'); + const refresh = pick(d, 'refresh_token'); + if (typeof access !== 'string' || typeof refresh !== 'string') { + return ctx.json({ ok: false, error: 'vault returned no token' }, 502); + } + await setVaultTokens(user.id, { + accessToken: access, + refreshToken: refresh, + expiresAt: expiryFrom(d, Date.now()), + deviceIdentifier: input.deviceIdentifier || null, + clientId, + }); + + // Ciphertext to us — passed straight to the client, which decrypts on-device with the master key. + return ctx.json({ + ok: true, + protectedUserKey: pick(d, 'Key') ?? null, + privateKey: pick(d, 'PrivateKey') ?? null, + kdf: { + kdf: pick(d, 'Kdf') ?? null, + kdfIterations: pick(d, 'KdfIterations') ?? null, + kdfMemory: pick(d, 'KdfMemory') ?? null, + kdfParallelism: pick(d, 'KdfParallelism') ?? null, + }, + }); +}); + +// Officer-app unlock path: store / release the platform-held protector key (owner session only). +vaultRouter.put('/unlock-key', async (ctx) => { + const user = ctx.get('user'); + const b = (await ctx.req.json().catch(() => ({}))) as Record; + if (typeof b.wrappedKey !== 'string' || !b.wrappedKey) return ctx.json({ error: 'wrappedKey required' }, 400); + await setVaultUnlockKey(user.id, b.wrappedKey); + return ctx.json({ ok: true }); +}); + +vaultRouter.get('/unlock-key', async (ctx) => { + const user = ctx.get('user'); + const wrappedKey = await getVaultUnlockKey(user.id); + if (!wrappedKey) return ctx.json({ error: 'no unlock key stored' }, 404); + return ctx.json({ wrappedKey }); +}); + +// ── Token-injecting proxy → sidecar → Vaultwarden ── vaultRouter.all('/*', async (ctx) => { const base = getVaultServerUrl(); if (!base) return ctx.text('Vault sidecar not available', 503); + const user = ctx.get('user'); const url = new URL(ctx.req.url); - // Strip the mount prefix so the sidecar sees Vaultwarden-root paths (/identity, /api, /notifications…). const subpath = url.pathname.slice(PREFIX.length) || '/'; const target = `${base}${subpath}${url.search}`; const method = ctx.req.method; const hasBody = method !== 'GET' && method !== 'HEAD'; const started = Date.now(); - // Bun/undici require half-duplex to stream a request body straight through (attachments can be large). - const init: RequestInit & { duplex?: 'half' } = { - method, - headers: stripHopByHop(ctx.req.raw.headers), - body: hasBody ? ctx.req.raw.body : undefined, - redirect: 'manual', - }; - if (hasBody) init.duplex = 'half'; + // Resolve the Vaultwarden token to inject (proactively refreshed if near expiry). No token yet → + // forward without one: unauthenticated endpoints (prelogin) work; authed ones get Vaultwarden's 401. + const tokenSet = await getVaultTokens(user.id); + let accessToken = tokenSet?.accessToken ?? null; + if (tokenSet?.expiresAt && tokenSet.expiresAt.getTime() < Date.now() + REFRESH_SKEW_MS) { + accessToken = (await refreshAccess(user.id, tokenSet)) ?? accessToken; + } + + const headers = stripHopByHop(ctx.req.raw.headers); + headers.delete('authorization'); // drop the platform JWT + if (accessToken) headers.set('authorization', `Bearer ${accessToken}`); + + const init: RequestInit & { duplex?: 'half' } = { method, headers, redirect: 'manual' }; + if (hasBody) { + init.body = ctx.req.raw.body; + init.duplex = 'half'; + } let upstream: Response; try { @@ -45,5 +175,15 @@ vaultRouter.all('/*', async (ctx) => { return ctx.text('Vault sidecar unreachable', 502); } + // A 401 on a replayable (no-body) request → token likely just expired/revoked: refresh once and retry. + if (upstream.status === 401 && !hasBody && tokenSet) { + const fresh = await refreshAccess(user.id, tokenSet); + if (fresh) { + headers.set('authorization', `Bearer ${fresh}`); + upstream = await fetch(target, { method, headers, redirect: 'manual' }).catch(() => upstream); + } + } + + console.log(`[vault] ${method} ${redactPath(subpath)} -> ${upstream.status} ${Date.now() - started}ms`); return new Response(upstream.body, { status: upstream.status, headers: stripHopByHop(upstream.headers) }); });