diff --git a/api/public-router.ts b/api/public-router.ts new file mode 100644 index 0000000..ac65454 --- /dev/null +++ b/api/public-router.ts @@ -0,0 +1,85 @@ +import { createRouter } from '@@/create-router'; +import { getVaultServerUrl } from './sidecar-server'; +import { stripHopByHop, redactPath } from './proxy-util'; + +// Vaultwarden, reachable through Officer with NO Officer authentication. +// +// ── Why this exists ── +// +// A Bitwarden client 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. The authenticated `/api/vault` router +// cannot serve it — that one 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. +// +// ── Why it is acceptable to leave unauthenticated ── +// +// It is not new exposure, it is the SAME exposure through one door instead of two: everything here was +// already reachable at the Vaultwarden URL it 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 because it cannot: it has no credential to check. +// +// What it buys is that Vaultwarden no longer has to be exposed at all. On this install it listens on +// 127.0.0.1 only, and Officer — already behind the tailnet, already terminating TLS — is the single +// address any client needs. That is a net reduction in surface, not an addition. +// +// ── TEMPORARY ── +// +// The end state is a first-party browser extension that authenticates to OFFICER, against the +// `/api/vault/session/login` broker, so Vaultwarden is never addressed directly by anything and this +// router is deleted rather than adjusted. Until then this is how a stock Bitwarden client reaches a vault +// that is not otherwise reachable. +// +// ── The rule that keeps it safe ── +// +// DUMB PASS-THROUGH, 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(); + +/** + * Where this router is mounted, under the plugin's own `/api/vault`. + * + * The path is stripped before forwarding, so `/api/vault/bw/identity/connect/token` reaches Vaultwarden + * as `/identity/connect/token`. A Bitwarden client is pointed at `https:///api/vault/bw` as its + * server URL and derives `/api`, `/identity`, `/icons` and `/notifications` from it itself — which is why + * one prefix is enough where the platform version needed four root-level mounts. + */ +const PREFIX = '/api/vault/bw'; + +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.startsWith(PREFIX) ? url.pathname.slice(PREFIX.length) : url.pathname) || '/'; + 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/api/router.ts b/api/router.ts index 86c11ef..73558e6 100644 --- a/api/router.ts +++ b/api/router.ts @@ -7,6 +7,7 @@ import { mountPrefix } from '@@/plugins/manifest'; import { onAccountLifecycle } from '@@/account-lifecycle'; import { manifest } from '../manifest'; import { getVaultServerUrl } from './sidecar-server'; +import { publicVaultRouter } from './public-router'; import { stripHopByHop, redactPath } from './proxy-util'; import { passwordGrant, pick, expiryFrom, type PasswordGrantInput } from './broker'; import { getValidAccessToken, refreshAccess } from './token-store'; @@ -42,6 +43,21 @@ const PREFIX = `/api${mountPrefix({ appName: 'vault', manifest })}`; export const router = createRouter(); +/* + The unauthenticated Bitwarden door, mounted FIRST — before the middleware below, deliberately. + + Hono collects matching handlers in registration order, so a route registered ahead of a `.use()` answers + without that middleware ever running. Verified rather than assumed, because it is an auth boundary: a + request to `/api/vault/bw/*` reaches the passthrough with no session, while `/api/vault/config` still + goes through `userMiddleware` and the owner gate. + + The platform is told about this through `unauthenticatedPaths` in the manifest, which is what makes it + skip `userMiddleware` and the permission gate for exactly this prefix. Declaring it is not optional — + without the declaration the platform gates it like anything else and a Bitwarden client gets a 401 it + cannot answer. +*/ +router.route('/bw', publicVaultRouter); + /** * Wipe this account's vault state when its session ends. Registered as a side effect of the router being * imported, which the platform does once when the plugin's routes are mounted — so the subscription's diff --git a/manifest.ts b/manifest.ts index 7a2f9f9..79e2b3a 100644 --- a/manifest.ts +++ b/manifest.ts @@ -36,4 +36,17 @@ export const manifest: PluginManifest = { description: 'The password vault, and the Officer-held session that opens it', }, ], + + /* + The Bitwarden door: served without an Officer session, because a stock Bitwarden client has nowhere to + put one. It carries its own Vaultwarden bearer token, which Vaultwarden itself judges — see + `api/public-router.ts` for why this is the same exposure through one door rather than a new one, and + why it is temporary. + + ONE prefix, not four. A Bitwarden client is given `https:///api/vault/bw` as its server URL + and derives `/api`, `/identity`, `/icons` and `/notifications` beneath it, so everything the protocol + needs sits under a single declared path. The platform resolves this against the plugin's own mount and + re-checks containment at boot, so it can only ever mean `/api/vault/bw/*`. + */ + unauthenticatedPaths: ['/bw/*'], };