The fourth plugin out, after offscale, music and email. Two routers, a sidecar,
three tables and — new — a settings screen, since the vault never had a UI in
the platform.
What it is: a reverse-proxy to a Vaultwarden you already run, plus a credential
broker that keeps the vault session server-side so devices never hold one. It
stores no passwords and cannot read any; every byte through it is ciphertext.
Two things changed rather than moved:
• VAULTWARDEN_URL left .env for a one-row `vault_config` table with a panel
writing it. A plugin cannot ask the owner to edit a file and restart a
process, so the env var could not survive the move. Server-wide, decided
with the owner: one Vaultwarden per install. The sidecar reads it on a 10s
cache rather than per request, since it sits in front of every proxied byte.
• Core stopped importing clearVaultTokens/clearVaultUnlockKey from three auth
handlers. That is the security property behind the distress and panic
buttons, so it could not simply be dropped — the platform gained a small
account-lifecycle hook instead, and the severity mapping moved here where it
belongs. Core now names no table and no column of this plugin.
Two things were deleted rather than carried, both already dark since
2026-08-13 and both recorded in PLUGIN.md:
• The unauthenticated Bitwarden passthrough. A plugin's routes mount behind
Officer's account gate; top-level unauthenticated prefixes are not something
a manifest can declare. Its own comments already called it TEMPORARY,
pending a first-party browser extension that authenticates to Officer.
• The notifications websocket. No plugin owns a socket on this platform yet.
SignalR falls back to HTTP long-polling on its own, so clients still sync.
288 lines
13 KiB
TypeScript
288 lines
13 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 { 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();
|
|
|
|
/**
|
|
* 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) });
|
|
});
|