Files
vault/api/router.ts
T
pastilhasandClaude Opus 5 4006be0e3b claim the root paths back, so the setup is the bare Officer URL again
Before the extraction this worked by pasting https://officer.example.com into a
Bitwarden client and logging in normally. The extraction moved the door to
/api/vault/bw, which needs five URLs configured by hand — technically the same
thing, practically nobody's setup.

Declares what it actually needs, now that the platform can express it:

  unauthenticatedRootPaths  /identity, /notifications, /icons, /events — the
                            four a Bitwarden client derives from a bare server
                            URL. None is a name Officer serves, so the path
                            alone decides and no sniffing is involved.

  protocolHeaders           bitwarden-client-name, bitwarden-client-version,
                            device-type — for /api/*, the one namespace both
                            servers claim, where the path cannot separate
                            Vaultwarden's /api/sync from Officer's /api/chat.

`publicRouter` is exported for the platform to mount above the gate. One router
serves all three doors: it strips /api/vault/bw when reached that way, and
otherwise the path is already Vaultwarden's own.

/bw stays. It costs nothing and remains the explicit address for a client that
would rather be told than infer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 14:47:20 +00:00

318 lines
14 KiB
TypeScript

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/<publisher>/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 <platform JWT> 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<string, unknown>;
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<Response> {
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<string, unknown>;
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<string, unknown> = { ...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<string, unknown>;
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) });
});