diff --git a/src/server.tsx b/src/server.tsx index b853b98a..fdffcba6 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -248,6 +248,11 @@ const server = serve({ '/.well-known/carddav': honoServer.fetch, '/dav': honoServer.fetch, '/dav/*': honoServer.fetch, + // Vaultwarden for third-party Bitwarden clients, unauthenticated at Officer's layer. Needs its own + // entry for the same reason /dav does: only the paths listed here reach hono, and anything else + // falls through to the SPA — which answers 200 with the React shell, so a missing line here looks + // like a working endpoint returning nonsense rather than a 404. + '/vaultwarden/*': honoServer.fetch, '/': officerWeb, '/*': officerWeb, '/api': honoServer.fetch, diff --git a/src/servers/api/vault/public-router.ts b/src/servers/api/vault/public-router.ts new file mode 100644 index 00000000..2d0411e3 --- /dev/null +++ b/src/servers/api/vault/public-router.ts @@ -0,0 +1,78 @@ +import { createRouter } from '../../create-router'; +import { getVaultServerUrl } from './sidecar-server'; +import { stripHopByHop, redactPath } from './proxy-util'; + +// Vaultwarden, reachable at Officer's own hostname with NO Officer authentication. +// +// ── Why this exists ── +// +// The Bitwarden browser extension speaks Vaultwarden's protocol and nothing else: it obtains its own +// bearer token from `/identity/connect/token` and has nowhere to put a platform JWT. `/api/vault` cannot +// serve it — that router requires an Officer session and REPLACES the caller's Authorization header with +// a server-held Vaultwarden token, which is the right design for our own clients (the device then holds +// no vault credential at all) and an impossible one for a third-party client. +// +// So this mount exists to retire a second public hostname. Andre pointed the extension straight at +// Vaultwarden; that URL can now go away, and the extension can use the platform's. +// +// ── Why it is acceptable to leave unauthenticated ── +// +// It is not a new exposure, it is the same exposure through one door instead of two: everything here was +// already reachable at the Vaultwarden URL this replaces, guarded by the same thing that guards it now — +// the vault's own master password and Vaultwarden's own auth. Officer adds no security to a Bitwarden +// client today because it cannot: it has no credential to check. +// +// It is also going behind Tailscale, so the surface is a private network rather than the internet. +// +// ── TEMPORARY ── +// +// The intended end state is a browser extension of our own, reusing `@officer/vault` (already proven to +// run as a plain JS bundle outside React Native — the iOS autofill extension hosts it in JavaScriptCore) +// against the `/api/vault/session/login` broker. Then the client authenticates to OFFICER, Vaultwarden is +// never addressed directly by anything, and this mount is deleted rather than adjusted. +// +// ── The rule that keeps it safe ── +// +// DUMB PASS-THROUGH, and more so than `/api/vault`: that one rewrites the Authorization header, this one +// touches nothing. Method, path, query, headers, status and both body streams go through verbatim. The +// vault is end-to-end encrypted and no part of this may parse, decrypt, buffer, cache or log a body — +// paths are redacted in logs for the same reason. Every line of "cleverness" added here is reachable +// without an Officer session, which is the whole reason it must stay boring. + +export const publicVaultRouter = createRouter(); + +const PREFIX = '/vaultwarden'; + +publicVaultRouter.all('/*', async (ctx) => { + const base = getVaultServerUrl(); + if (!base) return ctx.text('Vault sidecar not available', 503); + + const url = new URL(ctx.req.url); + const subpath = url.pathname.slice(PREFIX.length) || '/'; + const target = `${base}${subpath}${url.search}`; + const method = ctx.req.method; + const hasBody = method !== 'GET' && method !== 'HEAD'; + + // Authorization is FORWARDED, not replaced — the opposite of `/api/vault`. The client's Vaultwarden + // token is the only credential in play, and Vaultwarden is the only thing entitled to judge it. + const headers = stripHopByHop(ctx.req.raw.headers); + + const init: RequestInit & { duplex?: 'half' } = { method, headers, redirect: 'manual' }; + if (hasBody) { + init.body = ctx.req.raw.body; + init.duplex = 'half'; + } + + let upstream: Response; + try { + upstream = await fetch(target, init); + } catch { + console.error(`[vault:public] ${method} ${redactPath(subpath + url.search)} -> sidecar unreachable`); + return ctx.text('Vault sidecar unreachable', 502); + } + + // No 401-refresh-and-retry here, deliberately. That belongs to `/api/vault`, which owns the token it + // injected; this router holds no token and a 401 is Vaultwarden's answer to the CLIENT, which the + // client knows how to handle — it re-authenticates with the master password. + return new Response(upstream.body, { status: upstream.status, headers: stripHopByHop(upstream.headers) }); +}); diff --git a/src/servers/hono.ts b/src/servers/hono.ts index 99a8e447..ff96e9ae 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -23,6 +23,7 @@ import { taskLogsRouter } from './api/task-logs/task-logs'; import { router as fileBrowserRouter } from './api/file-browser/router'; import { musicRouter } from './api/music/router'; import { vaultRouter } from './api/vault/router'; +import { publicVaultRouter } from './api/vault/public-router'; import { agentHandoffRouter } from './api/agent-handoff/router'; import { slskdRouter } from './api/slskd/router'; import { headscaleRouter } from './api/headscale/router'; @@ -103,6 +104,14 @@ honoServer.route('/api/waitlist', waitlistRouter); // gating still applies via originScopeMiddleware above (OFFICER_VAULT_ORIGIN → /api/vault). The // notifications WebSocket is upgraded at the serve level (server.tsx). honoServer.route('/api/vault', vaultRouter); + +// The same Vaultwarden, with NO Officer authentication, so the Bitwarden browser extension can point at +// this host instead of at a second public hostname for Vaultwarden. Deliberately its own mount rather +// than a mode of the router above: that one requires an Officer session and swaps the caller's +// Authorization header for a server-held token, and blending the two would put an unauthenticated branch +// inside the authenticated path. Temporary — see public-router.ts for what replaces it and why leaving it +// open is not a new exposure. +honoServer.route('/vaultwarden', publicVaultRouter); honoServer.get('/api/integrations/google/callback', googleCallbackHandler); // Agent-to-agent handoff — mounted TOP-LEVEL for the same reason the vault is: the caller is a Claude