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 { 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 })}`; 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) }); });