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, };