From aae0fbd0ea3fb96a02f9b1ad6ffca2b6f73f9d2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Wed, 29 Jul 2026 02:44:55 +0000 Subject: [PATCH] vault: session-gated notifications WS + lifecycle cleanup - token-store: shared "give me a valid Vaultwarden access token" (proactive refresh) used by both the HTTP proxy and the WS; router refactored onto it. - notifications WS: validates the platform session in `open` (deferred, owner only), injects the stored Vaultwarden token into the upstream, and buffers client frames during the async setup so the SignalR handshake isn't dropped. The device connects with its platform JWT (?access_token=), never a vault one. - lifecycle: logout drops the vault token set (keeps the protector); distress (/auth/revoke) and panic wipe both token set and protector, forcing a one-time master-password re-setup. Co-Authored-By: Claude Opus 4.8 --- src/servers/api/auth/panic-handler.ts | 10 ++- src/servers/api/auth/revoke-handler.ts | 10 ++- src/servers/api/auth/signout.ts | 7 +- src/servers/api/vault/router.ts | 44 +++-------- src/servers/api/vault/token-store.ts | 34 +++++++++ src/servers/api/vault/websocket.ts | 102 ++++++++++++++++++------- 6 files changed, 138 insertions(+), 69 deletions(-) create mode 100644 src/servers/api/vault/token-store.ts diff --git a/src/servers/api/auth/panic-handler.ts b/src/servers/api/auth/panic-handler.ts index 78b36e69..a89806d7 100644 --- a/src/servers/api/auth/panic-handler.ts +++ b/src/servers/api/auth/panic-handler.ts @@ -1,12 +1,20 @@ import type { Handler } from 'hono'; +import { clearVaultTokens, clearVaultUnlockKey } from 'officerdb'; import { triggerLockdown } from './panic'; import { clientIp } from './client-ip'; // Trigger the panic lockdown. Authenticated (Bearer token) — no password in the body. Once tripped, // all logins and existing sessions are refused until the server is manually restarted. export const panicHandler: Handler = async (ctx) => { - const user = ctx.get('user') as { email?: string } | undefined; + const user = ctx.get('user') as { id?: number; email?: string } | undefined; const origin = ctx.get('origin') as string | undefined; triggerLockdown(`/panic (user=${user?.email || '?'}, origin=${origin || '-'}, ip=${clientIp(ctx)})`); + + // Panic wipes the vault session AND the protector key (like distress). + if (user?.id) { + clearVaultTokens(user.id).catch(() => {}); + clearVaultUnlockKey(user.id).catch(() => {}); + } + return ctx.json({ ok: true }); }; diff --git a/src/servers/api/auth/revoke-handler.ts b/src/servers/api/auth/revoke-handler.ts index 970c3bb4..531edcf4 100644 --- a/src/servers/api/auth/revoke-handler.ts +++ b/src/servers/api/auth/revoke-handler.ts @@ -1,10 +1,10 @@ import type { Handler } from 'hono'; -import { blacklistToken, cleanupExpiredTokens } from 'officerdb'; +import { blacklistToken, cleanupExpiredTokens, clearVaultTokens, clearVaultUnlockKey } from 'officerdb'; import { clientIp } from './client-ip'; // Distress: blacklist the current token (same as signout) but log it as a security event. export const revokeHandler: Handler = async (ctx) => { - const user = ctx.get('user') as { jti: string; exp: number; email?: string }; + const user = ctx.get('user') as { id: number; jti: string; exp: number; email?: string }; const origin = ctx.get('origin') as string | undefined; console.warn( @@ -12,6 +12,12 @@ export const revokeHandler: Handler = async (ctx) => { ); await blacklistToken(user.jti, user.exp); + + // Distress wipes the vault session AND the stored protector key — forces a one-time master-password + // re-setup, the correct "someone got in" behavior. + clearVaultTokens(user.id).catch(() => {}); + clearVaultUnlockKey(user.id).catch(() => {}); + cleanupExpiredTokens().catch(() => {}); return ctx.json({ ok: true }); diff --git a/src/servers/api/auth/signout.ts b/src/servers/api/auth/signout.ts index eb630a31..bc13bc49 100644 --- a/src/servers/api/auth/signout.ts +++ b/src/servers/api/auth/signout.ts @@ -1,11 +1,14 @@ import type { Handler } from 'hono'; -import { blacklistToken, cleanupExpiredTokens } from 'officerdb'; +import { blacklistToken, cleanupExpiredTokens, clearVaultTokens } from 'officerdb'; export const signoutHandler: Handler = async (ctx) => { - const user = ctx.get('user') as { jti: string; exp: number }; + const user = ctx.get('user') as { id: number; jti: string; exp: number }; await blacklistToken(user.jti, user.exp); + // Drop the brokered vault session on logout (the protector key stays, so re-login is frictionless). + clearVaultTokens(user.id).catch(() => {}); + // Opportunistic cleanup of expired tokens (non-blocking) cleanupExpiredTokens().catch(() => {}); diff --git a/src/servers/api/vault/router.ts b/src/servers/api/vault/router.ts index e47c914f..4fe9af78 100644 --- a/src/servers/api/vault/router.ts +++ b/src/servers/api/vault/router.ts @@ -5,15 +5,9 @@ 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'; +import { passwordGrant, pick, expiryFrom, type PasswordGrantInput } from './broker'; +import { getValidAccessToken, refreshAccess } from './token-store'; +import { getVaultTokens, setVaultTokens, getVaultUnlockKey, setVaultUnlockKey } from 'officerdb'; // Platform side of Officer Vault (VAULT_AUTH_SPEC.md). The device holds NO Vaultwarden token; a valid // platform session authorizes vault access. This router: @@ -26,7 +20,6 @@ import { 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. @@ -39,22 +32,6 @@ vaultRouter.use(originMiddleware); // set ctx 'origin' for userMiddleware's orig 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. @@ -149,13 +126,9 @@ vaultRouter.all('/*', async (ctx) => { const hasBody = method !== 'GET' && method !== 'HEAD'; const started = Date.now(); - // 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; - } + // Inject a valid Vaultwarden token (proactively refreshed near expiry). No token yet → forward without + // one: unauthenticated endpoints (prelogin) work; authed ones get Vaultwarden's 401. + const accessToken = await getValidAccessToken(user.id); const headers = stripHopByHop(ctx.req.raw.headers); headers.delete('authorization'); // drop the platform JWT @@ -176,8 +149,9 @@ vaultRouter.all('/*', async (ctx) => { } // 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 (upstream.status === 401 && !hasBody) { + const ts = await getVaultTokens(user.id); + const fresh = ts ? await refreshAccess(user.id, ts) : null; if (fresh) { headers.set('authorization', `Bearer ${fresh}`); upstream = await fetch(target, { method, headers, redirect: 'manual' }).catch(() => upstream); diff --git a/src/servers/api/vault/token-store.ts b/src/servers/api/vault/token-store.ts new file mode 100644 index 00000000..614a030f --- /dev/null +++ b/src/servers/api/vault/token-store.ts @@ -0,0 +1,34 @@ +import { getVaultTokens, updateVaultAccess, type VaultTokenSet } from 'officerdb'; +import { refreshGrant, pick, expiryFrom } from './broker'; + +// Read-side of the brokered Vaultwarden token: hand out a currently-valid access token, refreshing it +// server-side (with the stored refresh token) when it's near expiry. Shared by the HTTP proxy and the +// notifications-WS proxy so both inject a fresh token. The device never sees any of this. + +const REFRESH_SKEW_MS = 60_000; // refresh a bit before expiry to avoid racing a 401 + +/** Renew the access token from the stored refresh token; persist and return it, or null on failure. */ +export 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; +} + +/** A currently-valid Vaultwarden access token for the owner, or null if none is stored. */ +export async function getValidAccessToken(userId: number): Promise { + const ts = await getVaultTokens(userId); + if (!ts) return null; + if (ts.expiresAt && ts.expiresAt.getTime() < Date.now() + REFRESH_SKEW_MS) { + return (await refreshAccess(userId, ts)) ?? ts.accessToken; + } + return ts.accessToken; +} diff --git a/src/servers/api/vault/websocket.ts b/src/servers/api/vault/websocket.ts index 8c59e349..a4fa60af 100644 --- a/src/servers/api/vault/websocket.ts +++ b/src/servers/api/vault/websocket.ts @@ -1,48 +1,91 @@ import type { ServerWebSocket } from 'bun'; -import { getVaultServerWsUrl } from './sidecar-server'; +import { verify } from '../../jwt'; +import { isTokenBlacklisted } from 'officerdb'; +import { isSuperAdmin } from '../../super-admin'; import { isOriginAllowed } from '../../_middlewares'; +import { getVaultServerWsUrl } from './sidecar-server'; +import { getValidAccessToken } from './token-store'; -// Platform side of the Bitwarden notifications WebSocket: origin-gate the client upgrade, then pipe it to -// the officer-vault sidecar (which in turn pipes to Vaultwarden). Dumb pipe: text + binary frames both -// ways, no inspection. The platform holds NO Vaultwarden knowledge — only the sidecar's loopback address. -// SignalR's HTTP long-poll fallback does NOT arrive here — it rides the HTTP proxy (vaultRouter). +// Platform side of the Bitwarden notifications WebSocket. The device connects with its platform JWT (via +// ?access_token=, how SignalR carries the token); we validate that session in `open`, then pipe the socket +// to the officer-vault sidecar with the stored Vaultwarden token injected — the device never holds it. +// Dumb pipe: text + binary frames both ways, no inspection. SignalR's HTTP long-poll fallback rides the +// HTTP proxy (vaultRouter) instead. export type VaultWSData = { provider: 'vault'; - userId: number; - email: string; - username: string; - vaultWsPath: string; // subpath + query after /api/vault, e.g. /notifications/hub?access_token=… + platformToken: string; // the platform JWT the device presented on the upgrade + vaultWsPath: string; // path + query after /api/vault, e.g. /notifications/hub?access_token= vaultWsProtocol?: string; // requested Sec-WebSocket-Protocol, forwarded to the sidecar }; -type UpstreamState = { ws: WebSocket; queue: (string | Uint8Array)[]; ready: boolean }; +type UpstreamState = { + ws: WebSocket | null; + queue: (string | Uint8Array)[]; + ready: boolean; + closed: boolean; +}; const upstreams = new Map, UpstreamState>(); // Bun hands frames over as `string | Buffer`; a Buffer is a Uint8Array at runtime, so forward as-is. const asPayload = (raw: string | Buffer): string | Uint8Array => typeof raw === 'string' ? raw : (raw as Uint8Array); +function closeClient(ws: ServerWebSocket, state: UpstreamState, code: number, reason: string) { + state.closed = true; + upstreams.delete(ws); + try { + ws.close(code, reason); + } catch { + /* already closed */ + } +} + +// Swap the platform JWT in the path for the Vaultwarden access token before dialing upstream. +function injectToken(wsBase: string, vaultWsPath: string, vaultToken: string): string { + const qIdx = vaultWsPath.indexOf('?'); + const path = qIdx >= 0 ? vaultWsPath.slice(0, qIdx) : vaultWsPath; + const params = new URLSearchParams(qIdx >= 0 ? vaultWsPath.slice(qIdx + 1) : ''); + params.delete('token'); + params.set('access_token', vaultToken); + return `${wsBase}${path}?${params.toString()}`; +} + export const vaultWebsocket = { - open(ws: ServerWebSocket) { + async open(ws: ServerWebSocket) { + // Register state synchronously so frames sent during the async setup below are buffered, not dropped. + const state: UpstreamState = { ws: null, queue: [], ready: false, closed: false }; + upstreams.set(ws, state); + + // Deferred session validation (Bun requires the upgrade itself to be synchronous). + let userId: number; + try { + const payload = await verify(ws.data.platformToken); + if (!payload?.id) return closeClient(ws, state, 4001, 'Unauthorized'); + if (payload.jti && (await isTokenBlacklisted(payload.jti))) return closeClient(ws, state, 4001, 'Unauthorized'); + if (!(await isSuperAdmin(payload))) return closeClient(ws, state, 4001, 'Forbidden'); + userId = payload.id; + } catch { + return closeClient(ws, state, 4001, 'Unauthorized'); + } + if (state.closed) return; + + const vaultToken = await getValidAccessToken(userId); + if (!vaultToken) return closeClient(ws, state, 4001, 'No vault session'); const wsBase = getVaultServerWsUrl(); - if (!wsBase) { - ws.close(1011, 'Vault sidecar not available'); - return; - } + if (!wsBase) return closeClient(ws, state, 1011, 'Vault sidecar not available'); + if (state.closed) return; - const url = `${wsBase}${ws.data.vaultWsPath}`; const protocols = ws.data.vaultWsProtocol ? ws.data.vaultWsProtocol .split(',') .map((s) => s.trim()) .filter(Boolean) : undefined; + const url = injectToken(wsBase, ws.data.vaultWsPath, vaultToken); const upstream = protocols?.length ? new WebSocket(url, protocols) : new WebSocket(url); upstream.binaryType = 'arraybuffer'; - - const state: UpstreamState = { ws: upstream, queue: [], ready: false }; - upstreams.set(ws, state); + state.ws = upstream; upstream.addEventListener('open', () => { state.ready = true; @@ -77,14 +120,15 @@ export const vaultWebsocket = { const state = upstreams.get(ws); if (!state) return; const payload = asPayload(raw); - if (state.ready) state.ws.send(payload); - else state.queue.push(payload); // buffer until the sidecar socket opens + if (state.ready && state.ws) state.ws.send(payload); + else state.queue.push(payload); // buffer until the upstream socket opens }, close(ws: ServerWebSocket) { const state = upstreams.get(ws); if (state) { + state.closed = true; try { - state.ws.close(); + state.ws?.close(); } catch { /* already closed */ } @@ -96,21 +140,21 @@ export const vaultWebsocket = { const PREFIX = '/api/vault'; -// Serve-level upgrade for /api/vault/notifications/* WebSockets. Origin-gated: only an allow-listed origin -// (the OffVault app in prod; anything in dev) may connect — mirroring the HTTP origin gate. The Bitwarden -// access_token rides the query string and is forwarded untouched; we do NOT verify it as a platform JWT -// (it isn't one), matching the "don't inject platform session auth" caveat. +// Serve-level upgrade for /api/vault/notifications/* WebSockets. Origin-gated; the platform JWT rides the +// query (?access_token= for SignalR, or ?token=). The session is validated in `open` (deferred). The +// device never sends a Vaultwarden token — we inject the stored one upstream. export function upgradeVaultWs(req: Request, server: any): Response | undefined { const origin = req.headers.get('origin') ?? undefined; const host = req.headers.get('host') ?? undefined; if (!isOriginAllowed(origin, host)) return new Response('Forbidden', { status: 403 }); const url = new URL(req.url); + const platformToken = url.searchParams.get('access_token') || url.searchParams.get('token') || ''; + if (!platformToken) return new Response('Unauthorized', { status: 401 }); + const data: VaultWSData = { provider: 'vault', - userId: 0, - email: '', - username: '', + platformToken, vaultWsPath: url.pathname.slice(PREFIX.length) + url.search, vaultWsProtocol: req.headers.get('sec-websocket-protocol') ?? undefined, };