A stock Bitwarden client speaks Vaultwarden's protocol and nothing else: it gets its own bearer token from /identity/connect/token and has nowhere to put a platform JWT, so the authenticated /api/vault router cannot serve it. This was dropped during the extraction because a plugin's routes mount behind the account gate. It comes back through the platform's own mechanism rather than an exception: `unauthenticatedPaths: ['/bw/*']` in the manifest, resolved against this plugin's mount and re-checked for containment at boot. ONE prefix, not the four root mounts the platform version needed. A Bitwarden client takes `https://<officer>/api/vault/bw` as its server URL and derives /api, /identity, /icons and /notifications beneath it, so the whole protocol fits under a single declared path. The router is mounted BEFORE this file's middleware, deliberately: Hono collects handlers in registration order, so a route registered ahead of a `.use()` answers without it. Verified rather than assumed, because it is an auth boundary — /api/vault/bw/* reaches the passthrough with no session while /api/vault/config still goes through userMiddleware and the owner gate. Not new exposure: everything here was already reachable at the Vaultwarden URL it replaces, guarded by the same master password and the same Vaultwarden auth. Officer adds no security to a Bitwarden client because it has no credential to check. What it buys is that Vaultwarden need not be exposed at all — on this install it listens on 127.0.0.1 only, and Officer, already behind the tailnet and already terminating TLS, becomes the single address any client needs. Net reduction in surface. Still temporary, and still deleted rather than adjusted once a first-party extension authenticates to Officer against the /session/login broker. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
304 lines
14 KiB
TypeScript
304 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);
|
|
|
|
/**
|
|
* 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) });
|
|
});
|