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 { mountPrefix } from '@@/plugins/manifest'; import { onAccountLifecycle } from '@@/account-lifecycle'; import { registerProxiedPrefix } from '@@/sidecar/proxied-prefixes'; 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'; import { getVaultConfig, setVaultConfig, getVaultTokens, setVaultTokens, getVaultUnlockKey, setVaultUnlockKey, clearVaultTokens, clearVaultUnlockKey, } from '../db/queries'; // Platform side of Officer Vault. The device holds NO Vaultwarden token; a valid platform session // authorizes vault access. This router: // • gates everything on an owner platform session (userMiddleware, no bodyParser → streaming), // • serves the instance config, the native broker and the 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 prefix is DERIVED, not written ── // // It was the literal '/api/vault' before the extraction, which is right only by coincidence: // `mountPrefix` returns `/vault` for a first-party publisher, and the catch-all below slices exactly // `PREFIX.length` characters off the path to build the sidecar's. Published by anyone else this mounts at // `/api/p//vault` and a literal would forward `/alice/vault/identity/...` to a sidecar // expecting `/identity/...`. Same reasoning, and the same fix, as `plugins/music/api/router.ts`. // // `appName` is a literal for the reason recorded there too: the platform imports this module and reads // `router`, so there is nowhere for it to inject the plugin's own directory name. const PREFIX = `/api${mountPrefix({ appName: 'vault', manifest })}`; /* Tell bodyParser to keep its hands off this prefix. Everything under it is either forwarded verbatim to the sidecar or parsed by the handler itself (`ctx.req.json()`), so nothing here wants a body read for it. The platform's bodyParser reads JSON and form bodies on the protected router, which DISTURBS the request stream — and the catch-all proxy below then forwards `ctx.req.raw.body`, an already-consumed stream. Bun's fetch throws on it, the proxy reports 'sidecar unreachable', and the client is told the server is down while every bodyless GET keeps working. Core's own sidecars never hit this because `createSidecarProxy` registers the prefix for them. This router proxies by hand, so it has to say so itself — which extraction dropped on 2026-08-18. */ registerProxiedPrefix(PREFIX); 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); /** * The same router, exported for the platform to mount ABOVE the account gate. * * Mounted by the platform at the top-level paths this plugin's manifest claims (`/identity`, * `/notifications`, `/icons`, `/events`) and for `/api/*` requests carrying a Bitwarden client header. * That combination is what lets a Bitwarden client be given the bare Officer URL and nothing else — * which is the entire point, and what the `/bw` prefix above could not deliver: deriving five URLs by * hand into an advanced settings pane is not a setup anyone completes. * * `/bw` stays as well. It costs nothing (the router strips whichever prefix it was reached through) and * it is the explicit address for a client that would rather be told than infer. */ export const publicRouter = 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 * lifetime is "installed and enabled", with no lifecycle of its own to get wrong. * * The three levels are the platform's, and the severity mapping is ours: * * signout drop the brokered session, KEEP the protector key — logging out should not cost a * master-password re-setup, and the token is unusable without a platform session * anyway (the one that was just blacklisted). * distress / panic drop both. This is the "someone got in" path, so the convenience material goes * too and the next unlock needs the master password. Exactly what the platform-side * code did before the extraction; the mapping moved, the behaviour did not. * * Failures are swallowed per call, deliberately — `emitAccountLifecycle` already logs a rejection, and a * vault whose tables are missing (uninstalled, never pushed) must not turn signing out into a 500. */ onAccountLifecycle(async (userId, event) => { await clearVaultTokens(userId).catch(() => {}); if (event !== 'signout') await clearVaultUnlockKey(userId).catch(() => {}); }); // Owner-only, and this is the ONE gate that matters now. // // Before the extraction there were two: the platform's own backstop refused non-owner tokens on // `/api/vault`, and this repeated it. A plugin's permission is grantable per role by design — the rule is // uniform and has no `ownerOnly` — so a member could hold `vault` at read or write. That is fine for the // permission (it decides whether the ROUTE is reachable) and not fine for the vault (one Vaultwarden // instance, one brokered token set, all of it the owner's). So the router keeps its own answer rather // than inheriting one, which is exactly what the plugin contract says a plugin's queries are for. const ownerGate: MiddlewareHandler = async (ctx, next) => { if (!(await isSuperAdmin(ctx.get('user')))) throw errors.FORBIDDEN('Vault is owner-only'); return next(); }; router.use(originMiddleware); // set ctx 'origin' for userMiddleware's origin check router.use(userMiddleware); // valid platform session (Authorization: Bearer or ?token=) router.use(ownerGate); // ── Instance configuration (registered before the catch-all) ── /** * GET /api/vault/config → `{ url, enabled, updatedAt, configured }` * * `configured` is derived rather than stored: a client wants to know "can this do anything yet", and * that is a URL being present, not a row existing. */ router.get('/config', async (ctx) => { const config = await getVaultConfig(); return ctx.json({ ...config, configured: !!config.url }); }); /** * PUT /api/vault/config `{ url?: string | null, enabled?: boolean }` * * Refuses anything that is not an absolute http(s) URL. Not a security boundary — this is owner-only and * the value reaches `fetch`, never a shell — but a typo saved here fails later as "upstream unreachable", * which sends the reader looking at their Vaultwarden instead of at what they typed. */ router.put('/config', async (ctx) => { const body = (await ctx.req.json().catch(() => ({}))) as Record; const patch: { url?: string | null; enabled?: boolean } = {}; if (body.url !== undefined) { if (body.url === null || body.url === '') { patch.url = null; } else if (typeof body.url !== 'string') { throw errors.BAD_REQUEST('url must be a string, or null to clear it'); } else { let parsed: URL; try { parsed = new URL(body.url.trim()); } catch { throw errors.BAD_REQUEST('url must be an absolute URL, e.g. https://vault.example.com'); } if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { throw errors.BAD_REQUEST('url must be http:// or https://'); } patch.url = body.url.trim(); } } if (body.enabled !== undefined) { if (typeof body.enabled !== 'boolean') throw errors.BAD_REQUEST('enabled must be a boolean'); patch.enabled = body.enabled; } if (patch.url === undefined && patch.enabled === undefined) { throw errors.BAD_REQUEST('nothing to update — send url and/or enabled'); } const config = await setVaultConfig(patch); return ctx.json({ ...config, configured: !!config.url }); }); /** * GET /api/vault/config/health — does the configured Vaultwarden actually answer? * * Forwards to the sidecar's own `/_health`, which probes Vaultwarden's `/alive`. Kept separate from * `GET /config` so that reading the setting never blocks on a network round trip to a box that may be * off — the panel renders the URL immediately and fills the status in behind it. */ async function probeHealth(): Promise { const base = getVaultServerUrl(); if (!base) return Response.json({ ok: false, error: 'vault sidecar unavailable' }, { status: 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 Response.json({ ok: false, error: 'vault sidecar unreachable' }, { status: 502 }); } } router.get('/config/health', () => probeHealth()); // The same answer at its original path: the phone app already probes `/_health`, and renaming it would be // a client change for no reason. `/config/health` is the name the settings panel reads better. router.get('/_health', () => probeHealth()); // ── Native endpoints ── // 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. router.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, }); // Everything except the transport tokens (which the platform holds) is crypto material the SDK unlocks // on-device — ciphertext to us. Vaultwarden returns BOTH classic (Key/PrivateKey/Kdf*) and v2 // (UserDecryptionOptions.MasterPasswordUnlock, AccountKeys); pass the whole native response through // under `connectToken` so the SDK gets whatever it uses, alongside the spec's named aliases. const connectToken: Record = { ...d }; for (const k of ['access_token', 'refresh_token', 'token_type', 'expires_in']) delete connectToken[k]; 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, }, connectToken, }); }); // Officer-app unlock path: store / release the platform-held protector key (owner session only). router.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 }); }); router.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 ── router.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); 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(); // 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 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 { upstream = await fetch(target, init); } catch { console.error(`[vault] ${method} ${redactPath(subpath + url.search)} -> sidecar unreachable`); 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) { 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); } } console.log(`[vault] ${method} ${redactPath(subpath)} -> ${upstream.status} ${Date.now() - started}ms`); return new Response(upstream.body, { status: upstream.status, headers: stripHopByHop(upstream.headers) }); });