vault, extracted into plugins/vault — the platform half

Fourth extraction, after offscale, music and email. Everything vault is now at
gitea.officer.dev/plugins/vault and installs from the marketplace; this commit
is what the platform stopped knowing.

Deleted, not commented: both routers, the sidecar, the db directory, the
hono.ts mounts/imports, server.tsx's ws import + handler entry + four
route-table entries, '/vault' from UNPROTECTED_API_PREFIXES, both entries in
totality.ts's exemption lists, both database barrels, and the app-store
catalogue entry.

The catalogue deletion is required rather than tidying — the trap the runbook
records as having bitten twice. permissionAvailability() derives from
sidecar_installs, which a plugin never gets a row in, so a leftover entry makes
the permission permanently 'unavailable' and withholds the route and dock tile
on a server where the plugin is installed and working.

── The one new piece of platform: src/servers/account-lifecycle.ts ──

Three auth handlers (signout, distress, panic) imported clearVaultTokens and
clearVaultUnlockKey directly. That is the security property behind the distress
and panic buttons — "someone got in, make the vault need a master password
again" — so it could not just be dropped, and a plugin cannot be imported by
core.

Rejected: having core DELETE from the plugin's tables by name. It removes the
import but moves the coupling somewhere worse — core would depend on a plugin's
SCHEMA, so renaming a column would silently stop the wipe. A security property
that fails silently on an unrelated change is the failure mode to design
against. (docs/email-plugin-extraction.md took that shape for READING a
plugin's data, where a stale answer is a missing feature rather than a lock
left open. The stakes decide it, not the pattern.)

Taken instead: core announces 'signout' | 'distress' | 'panic' with a user id,
and anything holding per-account state subscribes. ~30 lines, fire-and-forget,
one listener's failure cannot stop another. Core names no plugin, no table and
no column; a listener that is not there is a plugin that is not installed. The
severity mapping moved into the plugin — signout keeps the protector key,
distress and panic drop it — so the behaviour is unchanged.

── Two things deleted rather than carried, both dark since 2026-08-13 ──

The unauthenticated Bitwarden passthrough (/vaultwarden plus four root
prefixes and an /api/* header diverter). A plugin's routes mount into
protectedRouter at /api/<name>, behind the account gate; top-level
unauthenticated prefixes are not something a manifest can declare, and the Bun
route table they need entries in is built once at serve(). Keeping it meant
core naming /vaultwarden and /identity in its own route table — the vocabulary
leak the runbook forbids, for a path its own comments called TEMPORARY pending
a first-party browser extension. Owner's call: nice to have, not a requirement.

The notifications websocket, for the same reason plus a second: no plugin owns
a socket on this platform yet (server.reload({routes}) proven, never called;
handlers in server.tsx is a static map). Music parked cliamp/ for exactly this.
SignalR degrades to HTTP long-polling by itself, so clients still sync.

── Tests ──

Vault was the ONLY members: 'invite' catalogue entry, so deleting it would have
silently dropped coverage of that branch in plannedOutcome. It is now tested
against a synthetic entry instead: deleting an assertion along with the last
real user of a case is how a switch arm stops being covered without anyone
deciding it should.

bunx tsgo clean (verified it really does cover plugins/ by injecting a
deliberate error and watching it fail). Suite back at its 7 pre-existing
baseline failures — none of them this feature's, all confirmed unrelated
(the totality ones are about 'desktop'). 85 test files, unchanged, per the
runbook's trap about a moved test vanishing silently.

Needs `bun db:push` and a restart before installing, then point it at a
Vaultwarden from /vault — VAULTWARDEN_URL in .env is no longer read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 13:43:32 +00:00
co-authored by Claude Opus 5
parent c775e8c7e3
commit f6a1194905
27 changed files with 141 additions and 1150 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ export const bootstrapHandler: Handler = async function (ctx) {
// The role is what makes this account the owner — isSuperAdmin and getOwnerUser both read it, and
// nothing else confers it. Without this the first account would take the column's 'Member' default
// and the platform would come up with no owner at all: no vault, no agent identity, and the web
// and the platform would come up with no owner at all: no owner-only surfaces, no agent identity, and the web
// origin locked to a Super Admin that does not exist. Bootstrap is gated on an empty user table
// above, so this cannot promote anyone but the first account.
const user = await createUser({
+3 -6
View File
@@ -1,5 +1,5 @@
import type { Handler } from 'hono';
import { clearVaultTokens, clearVaultUnlockKey } from 'officerdb';
import { emitAccountLifecycle } from '@@/account-lifecycle';
import { triggerLockdown } from './panic';
import { clientIp } from './client-ip';
@@ -10,11 +10,8 @@ export const panicHandler: Handler = async (ctx) => {
const origin = ctx.get('origin') as string | undefined;
triggerLockdown(`/panic (user=${user?.email || '?'}, origin=${origin || '-'}, ip=${clientIp(ctx)})`);
// Panic wipes the vault session AND the protector key (like distress).
if (user?.id) {
clearVaultTokens(user.id).catch(() => {});
clearVaultUnlockKey(user.id).catch(() => {});
}
// Panic is distress plus a locked-down server, so listeners get the same severity.
if (user?.id) emitAccountLifecycle(user.id, 'panic');
return ctx.json({ ok: true });
};
+6 -5
View File
@@ -1,5 +1,6 @@
import type { Handler } from 'hono';
import { blacklistToken, cleanupExpiredTokens, clearVaultTokens, clearVaultUnlockKey } from 'officerdb';
import { blacklistToken, cleanupExpiredTokens } from 'officerdb';
import { emitAccountLifecycle } from '@@/account-lifecycle';
import { clientIp } from './client-ip';
// Distress: blacklist the current token (same as signout) but log it as a security event.
@@ -13,10 +14,10 @@ export const revokeHandler: Handler = async (ctx) => {
await blacklistToken(user.jti, user.exp);
// Distress wipes the vault session AND the stored protector key — forces a one-time master-password
// re-setup, the correct "someone got in" behavior.
clearVaultTokens(user.id).catch(() => {});
clearVaultUnlockKey(user.id).catch(() => {});
// Distress means "someone got in": listeners drop everything reconstructible, not just live sessions.
// Deliberately harsher than signout — convenience material goes too, even where that costs the owner a
// one-time re-setup on their next login.
emitAccountLifecycle(user.id, 'distress');
cleanupExpiredTokens().catch(() => {});
+5 -3
View File
@@ -1,6 +1,7 @@
import type { Handler } from 'hono';
import type { AuthIdentity } from '@@/auth-token';
import { blacklistToken, cleanupExpiredTokens, clearVaultTokens } from 'officerdb';
import { blacklistToken, cleanupExpiredTokens } from 'officerdb';
import { emitAccountLifecycle } from '@@/account-lifecycle';
export const signoutHandler: Handler = async (ctx) => {
const user = ctx.get('user') as AuthIdentity;
@@ -9,8 +10,9 @@ export const signoutHandler: Handler = async (ctx) => {
// the keys API, which is the point of holding one instead of a session.
if (user.jti && user.exp) await blacklistToken(user.jti, user.exp);
// Drop the brokered vault session on logout (the protector key stays, so re-login is frictionless).
clearVaultTokens(user.id).catch(() => {});
// Tell whatever holds per-account session state that this one ended. The mildest of the three levels:
// a listener should drop live sessions and keep whatever makes signing back in painless.
emitAccountLifecycle(user.id, 'signout');
// Opportunistic cleanup of expired tokens (non-blocking)
cleanupExpiredTokens().catch(() => {});
-79
View File
@@ -1,79 +0,0 @@
import { getVaultServerUrl } from './sidecar-server';
// Vaultwarden token broker. The platform (not the device) obtains and refreshes the Vaultwarden token via
// the sidecar's `/identity/connect/token`. We deal in the OAuth-style form grants only; the master
// password never appears here — the client sends a pre-derived `authHash` (Bitwarden's master password
// hash), exactly what Vaultwarden itself checks.
export type ConnectResult = { ok: true; data: Record<string, unknown> } | { ok: false; status: number; error: string };
async function postConnectToken(form: URLSearchParams): Promise<ConnectResult> {
const base = getVaultServerUrl();
if (!base) return { ok: false, status: 503, error: 'vault sidecar unavailable' };
let res: Response;
try {
res = await fetch(`${base}/identity/connect/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: form.toString(),
signal: AbortSignal.timeout(30_000),
});
} catch {
return { ok: false, status: 502, error: 'vault upstream unreachable' };
}
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>;
if (!res.ok) {
// Vaultwarden puts the human message in different places (error_description/error are often empty on a
// bad login; the real text is in `message` or `errorModel.message`). Check them all.
const errorModel = data.errorModel as Record<string, unknown> | undefined;
const candidates = [data.error_description, errorModel?.message, data.message, data.error];
const desc = candidates.find((c): c is string => typeof c === 'string' && c.length > 0) ?? 'vault login failed';
return { ok: false, status: res.status, error: desc };
}
return { ok: true, data };
}
export type PasswordGrantInput = {
email: string;
authHash: string;
clientId: string;
deviceIdentifier: string;
deviceType: string;
deviceName: string;
};
/** Owner login: exchange the client-derived auth hash for a Vaultwarden token set. */
export function passwordGrant(p: PasswordGrantInput): Promise<ConnectResult> {
const form = new URLSearchParams();
form.set('grant_type', 'password');
form.set('username', p.email);
form.set('password', p.authHash);
form.set('scope', 'api offline_access'); // offline_access → a refresh token comes back
form.set('client_id', p.clientId);
form.set('deviceIdentifier', p.deviceIdentifier);
form.set('deviceType', p.deviceType);
form.set('deviceName', p.deviceName);
return postConnectToken(form);
}
/** Renew the access token from the stored refresh token (no password needed). */
export function refreshGrant(refreshToken: string, clientId: string): Promise<ConnectResult> {
const form = new URLSearchParams();
form.set('grant_type', 'refresh_token');
form.set('refresh_token', refreshToken);
form.set('client_id', clientId);
return postConnectToken(form);
}
// Vaultwarden mixes PascalCase and camelCase field names across versions — read a field case-insensitively.
export function pick(data: Record<string, unknown>, name: string): unknown {
const lower = name.charAt(0).toLowerCase() + name.slice(1);
const upper = name.charAt(0).toUpperCase() + name.slice(1);
return data[name] ?? data[lower] ?? data[upper];
}
/** Compute an absolute expiry from a token response's `expires_in` (seconds), if present. */
export function expiryFrom(data: Record<string, unknown>, now: number): Date | null {
const secs = data.expires_in;
return typeof secs === 'number' && Number.isFinite(secs) ? new Date(now + secs * 1000) : null;
}
-29
View File
@@ -1,29 +0,0 @@
// Generic proxy header hygiene for the platform-side /api/vault forwarder. The platform knows nothing
// about Vaultwarden — it only forwards faithfully to the officer-vault sidecar (loopback). Bodies are
// never touched; we only drop hop-by-hop headers and redact token-shaped query strings from logs.
const HOP_BY_HOP = new Set([
'connection',
'keep-alive',
'proxy-authenticate',
'proxy-authorization',
'te',
'trailer',
'transfer-encoding',
'upgrade',
'host',
]);
/** Copy headers, dropping only hop-by-hop / proxy-* ones. Authorization + device headers pass through. */
export function stripHopByHop(src: Headers): Headers {
const out = new Headers();
src.forEach((value, key) => {
const k = key.toLowerCase();
if (HOP_BY_HOP.has(k) || k.startsWith('proxy-')) return;
out.set(key, value);
});
return out;
}
const REDACT = /([?&](access_token|refresh_token|code|token)=)[^&]*/gi;
export const redactPath = (pathAndQuery: string): string => pathAndQuery.replace(REDACT, '$1<redacted>');
-110
View File
@@ -1,110 +0,0 @@
import { createRouter } from '../../create-router';
import { getVaultServerUrl } from './sidecar-server';
import { stripHopByHop, redactPath } from './proxy-util';
// Vaultwarden, reachable at Officer's own hostname with NO Officer authentication.
//
// ── Why this exists ──
//
// The Bitwarden browser extension speaks Vaultwarden's protocol and nothing else: it obtains its own
// bearer token from `/identity/connect/token` and has nowhere to put a platform JWT. `/api/vault` cannot
// serve it — that router requires an Officer session and REPLACES the caller's Authorization header with
// a server-held Vaultwarden token, which is the right design for our own clients (the device then holds
// no vault credential at all) and an impossible one for a third-party client.
//
// So this mount exists to retire a second public hostname. Andre pointed the extension straight at
// Vaultwarden; that URL can now go away, and the extension can use the platform's.
//
// ── Why it is acceptable to leave unauthenticated ──
//
// It is not a new exposure, it is the same exposure through one door instead of two: everything here was
// already reachable at the Vaultwarden URL this replaces, guarded by the same thing that guards it now —
// the vault's own master password and Vaultwarden's own auth. Officer adds no security to a Bitwarden
// client today because it cannot: it has no credential to check.
//
// It is also going behind Tailscale, so the surface is a private network rather than the internet.
//
// ── TEMPORARY ──
//
// The intended end state is a browser extension of our own, reusing `@officer/vault` (already proven to
// run as a plain JS bundle outside React Native — the iOS autofill extension hosts it in JavaScriptCore)
// against the `/api/vault/session/login` broker. Then the client authenticates to OFFICER, Vaultwarden is
// never addressed directly by anything, and this mount is deleted rather than adjusted.
//
// ── The rule that keeps it safe ──
//
// DUMB PASS-THROUGH, and more so than `/api/vault`: that one rewrites the Authorization header, this one
// touches nothing. Method, path, query, headers, status and both body streams go through verbatim. The
// vault is end-to-end encrypted and no part of this may parse, decrypt, buffer, cache or log a body —
// paths are redacted in logs for the same reason. Every line of "cleverness" added here is reachable
// without an Officer session, which is the whole reason it must stay boring.
export const publicVaultRouter = createRouter();
const PREFIX = '/vaultwarden';
/**
* Paths that belong to Vaultwarden and to nothing else in this platform.
*
* These are served at the ROOT so the extension can be configured with the bare Officer URL — no
* `/vaultwarden` suffix — which is the point: one hostname, one URL, nothing to explain. None of them
* collide with an Officer route, so no sniffing is involved; the path alone is the answer.
*/
export const VAULT_ONLY_PREFIXES = ['/identity', '/notifications', '/icons', '/events'];
/**
* Is this a Bitwarden client talking to us?
*
* Needed only for `/api/*`, which is the one namespace both servers claim — Vaultwarden has
* `/api/sync`, `/api/ciphers`, `/api/settings/domains`; Officer has `/api/chat`, `/api/settings` and
* everything else. The path cannot decide it, so the client says who it is: every Bitwarden client
* stamps `Bitwarden-Client-Name` (`browser`, `desktop`, `cli`, `web`), and older ones `Device-Type`.
*
* This is ROUTING, not authentication, which is why trusting a client-supplied header is fine here: the
* worst a forged header achieves is reaching Vaultwarden, which then demands its own credential exactly
* as it would have. Nothing is authorised by this function.
*/
export const isBitwardenClient = (headers: Headers): boolean =>
headers.has('bitwarden-client-name') || headers.has('bitwarden-client-version') || headers.has('device-type');
/** Everything Vaultwarden serves, whichever door it arrived through. */
export const isVaultwardenPath = (path: string, headers: Headers): boolean => {
if (VAULT_ONLY_PREFIXES.some((p) => path === p || path.startsWith(`${p}/`))) return true;
return path.startsWith('/api/') && isBitwardenClient(headers);
};
publicVaultRouter.all('/*', async (ctx) => {
const base = getVaultServerUrl();
if (!base) return ctx.text('Vault sidecar not available', 503);
const url = new URL(ctx.req.url);
// Mounted at `/vaultwarden` AND at the root prefixes above. Strip the prefix only when it is there,
// so `/vaultwarden/identity/...` and a bare `/identity/...` both reach Vaultwarden's own path.
const subpath = (url.pathname.startsWith(PREFIX) ? url.pathname.slice(PREFIX.length) : url.pathname) || '/';
const target = `${base}${subpath}${url.search}`;
const method = ctx.req.method;
const hasBody = method !== 'GET' && method !== 'HEAD';
// Authorization is FORWARDED, not replaced — the opposite of `/api/vault`. The client's Vaultwarden
// token is the only credential in play, and Vaultwarden is the only thing entitled to judge it.
const headers = stripHopByHop(ctx.req.raw.headers);
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:public] ${method} ${redactPath(subpath + url.search)} -> sidecar unreachable`);
return ctx.text('Vault sidecar unreachable', 502);
}
// No 401-refresh-and-retry here, deliberately. That belongs to `/api/vault`, which owns the token it
// injected; this router holds no token and a 401 is Vaultwarden's answer to the CLIENT, which the
// client knows how to handle — it re-authenticates with the master password.
return new Response(upstream.body, { status: upstream.status, headers: stripHopByHop(upstream.headers) });
});
-168
View File
@@ -1,168 +0,0 @@
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 { 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 { getVaultTokens, setVaultTokens, getVaultUnlockKey, setVaultUnlockKey } from 'officerdb';
// Platform side of Officer Vault (VAULT_AUTH_SPEC.md). The device holds NO Vaultwarden token; a valid
// platform session authorizes vault access. This router:
// • gates every /api/vault/* on an owner platform session (userMiddleware, no bodyParser → streaming),
// • serves the native broker/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.
export const vaultRouter = createRouter();
const PREFIX = '/api/vault';
// Owner-only. The global backstop already blocks non-owner tokens from /api/vault; this is explicit
// defense-in-depth and gives a clear error.
const ownerGate: MiddlewareHandler = async (ctx, next) => {
if (!(await isSuperAdmin(ctx.get('user')))) throw errors.FORBIDDEN('Vault is owner-only');
return next();
};
vaultRouter.use(originMiddleware); // set ctx 'origin' for userMiddleware's origin check
vaultRouter.use(userMiddleware); // valid platform session (Authorization: Bearer <platform JWT> or ?token=)
vaultRouter.use(ownerGate);
// ── Native endpoints (registered before the catch-all) ──
// Reachability probe (owner-gated): forwards to the sidecar's /_health, which probes Vaultwarden's /alive.
vaultRouter.get('/_health', async (ctx) => {
const base = getVaultServerUrl();
if (!base) return ctx.json({ ok: false, error: 'vault sidecar unavailable' }, 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 ctx.json({ ok: false, error: 'vault sidecar unreachable' }, 502);
}
});
// 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.
vaultRouter.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).
vaultRouter.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 });
});
vaultRouter.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 ──
vaultRouter.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) });
});
-24
View File
@@ -1,24 +0,0 @@
import * as sidecar from '@@/sidecar-registry';
// The officer-vault sidecar starts its Vaultwarden reverse-proxy on a random loopback port and reports it
// here on connect. We remember it so `/api/vault/*` (HTTP) and the notifications WebSocket always forward
// to the current sidecar. The platform holds NO knowledge of Vaultwarden itself — only where the sidecar is.
let serverPort: number | null = null;
sidecar.on('vault:server', (msg) => {
const port = (msg as { port?: number }).port;
if (typeof port !== 'number') return;
serverPort = port;
console.log(`[vault] sidecar proxy registered on port ${port}`);
});
/** Base URL of the sidecar's HTTP proxy, or null if the sidecar hasn't reported in yet. */
export function getVaultServerUrl(): string | null {
return serverPort ? `http://127.0.0.1:${serverPort}` : null;
}
/** Same, as a WebSocket base for the notifications hub. */
export function getVaultServerWsUrl(): string | null {
return serverPort ? `ws://127.0.0.1:${serverPort}` : null;
}
-34
View File
@@ -1,34 +0,0 @@
import { getVaultTokens, updateVaultAccess, type VaultTokenSet } from 'officerdb';
import { refreshGrant, pick, expiryFrom } from './broker';
// Read-side of the brokered Vaultwarden token: hand out a currently-valid access token, refreshing it
// server-side (with the stored refresh token) when it's near expiry. Shared by the HTTP proxy and the
// notifications-WS proxy so both inject a fresh token. The device never sees any of this.
const REFRESH_SKEW_MS = 60_000; // refresh a bit before expiry to avoid racing a 401
/** Renew the access token from the stored refresh token; persist and return it, or null on failure. */
export async function refreshAccess(userId: number, ts: VaultTokenSet): Promise<string | null> {
const r = await refreshGrant(ts.refreshToken, ts.clientId ?? 'mobile');
if (!r.ok) return null;
const access = pick(r.data, 'access_token');
const refresh = pick(r.data, 'refresh_token');
if (typeof access !== 'string') return null;
await updateVaultAccess(
userId,
access,
typeof refresh === 'string' ? refresh : ts.refreshToken,
expiryFrom(r.data, Date.now()),
);
return access;
}
/** A currently-valid Vaultwarden access token for the owner, or null if none is stored. */
export async function getValidAccessToken(userId: number): Promise<string | null> {
const ts = await getVaultTokens(userId);
if (!ts) return null;
if (ts.expiresAt && ts.expiresAt.getTime() < Date.now() + REFRESH_SKEW_MS) {
return (await refreshAccess(userId, ts)) ?? ts.accessToken;
}
return ts.accessToken;
}
-162
View File
@@ -1,162 +0,0 @@
import type { ServerWebSocket } from 'bun';
import { resolveAuthToken } from '../../auth-token';
import { isTokenBlacklisted } from 'officerdb';
import { isSuperAdmin } from '../../super-admin';
import { getVaultServerWsUrl } from './sidecar-server';
import { getValidAccessToken } from './token-store';
// Platform side of the Bitwarden notifications WebSocket. The device connects with its platform JWT (via
// ?access_token=, how SignalR carries the token); we validate that session in `open`, then pipe the socket
// to the officer-vault sidecar with the stored Vaultwarden token injected — the device never holds it.
// Dumb pipe: text + binary frames both ways, no inspection. SignalR's HTTP long-poll fallback rides the
// HTTP proxy (vaultRouter) instead.
export type VaultWSData = {
provider: 'vault';
platformToken: string; // the platform JWT the device presented on the upgrade
vaultWsPath: string; // path + query after /api/vault, e.g. /notifications/hub?access_token=<jwt>
vaultWsProtocol?: string; // requested Sec-WebSocket-Protocol, forwarded to the sidecar
};
type UpstreamState = {
ws: WebSocket | null;
queue: (string | Uint8Array<ArrayBuffer>)[];
ready: boolean;
closed: boolean;
};
const upstreams = new Map<ServerWebSocket<VaultWSData>, UpstreamState>();
// Bun hands frames over as `string | Buffer`; a Buffer is a Uint8Array at runtime, so forward as-is.
const asPayload = (raw: string | Buffer): string | Uint8Array<ArrayBuffer> =>
typeof raw === 'string' ? raw : (raw as Uint8Array<ArrayBuffer>);
function closeClient(ws: ServerWebSocket<VaultWSData>, state: UpstreamState, code: number, reason: string) {
state.closed = true;
upstreams.delete(ws);
try {
ws.close(code, reason);
} catch {
/* already closed */
}
}
// Swap the platform JWT in the path for the Vaultwarden access token before dialing upstream.
function injectToken(wsBase: string, vaultWsPath: string, vaultToken: string): string {
const qIdx = vaultWsPath.indexOf('?');
const path = qIdx >= 0 ? vaultWsPath.slice(0, qIdx) : vaultWsPath;
const params = new URLSearchParams(qIdx >= 0 ? vaultWsPath.slice(qIdx + 1) : '');
params.delete('token');
params.set('access_token', vaultToken);
return `${wsBase}${path}?${params.toString()}`;
}
export const vaultWebsocket = {
async open(ws: ServerWebSocket<VaultWSData>) {
// Register state synchronously so frames sent during the async setup below are buffered, not dropped.
const state: UpstreamState = { ws: null, queue: [], ready: false, closed: false };
upstreams.set(ws, state);
// Deferred session validation (Bun requires the upgrade itself to be synchronous).
let userId: number;
try {
const payload = await resolveAuthToken(ws.data.platformToken);
if (!payload?.id) return closeClient(ws, state, 4001, 'Unauthorized');
if (payload.jti && (await isTokenBlacklisted(payload.jti))) return closeClient(ws, state, 4001, 'Unauthorized');
if (!(await isSuperAdmin(payload))) return closeClient(ws, state, 4001, 'Forbidden');
userId = payload.id;
} catch {
return closeClient(ws, state, 4001, 'Unauthorized');
}
if (state.closed) return;
const vaultToken = await getValidAccessToken(userId);
if (!vaultToken) return closeClient(ws, state, 4001, 'No vault session');
const wsBase = getVaultServerWsUrl();
if (!wsBase) return closeClient(ws, state, 1011, 'Vault sidecar not available');
if (state.closed) return;
const protocols = ws.data.vaultWsProtocol
? ws.data.vaultWsProtocol
.split(',')
.map((s) => s.trim())
.filter(Boolean)
: undefined;
const url = injectToken(wsBase, ws.data.vaultWsPath, vaultToken);
const upstream = protocols?.length ? new WebSocket(url, protocols) : new WebSocket(url);
upstream.binaryType = 'arraybuffer';
state.ws = upstream;
upstream.addEventListener('open', () => {
state.ready = true;
for (const m of state.queue) upstream.send(m);
state.queue.length = 0;
});
upstream.addEventListener('message', (ev) => {
try {
ws.send(ev.data as string | ArrayBuffer);
} catch {
/* client gone */
}
});
upstream.addEventListener('close', (ev) => {
upstreams.delete(ws);
try {
ws.close(ev.code || 1000, ev.reason || '');
} catch {
/* already closed */
}
});
upstream.addEventListener('error', () => {
upstreams.delete(ws);
try {
ws.close(1011, 'upstream error');
} catch {
/* already closed */
}
});
},
message(ws: ServerWebSocket<VaultWSData>, raw: string | Buffer) {
const state = upstreams.get(ws);
if (!state) return;
const payload = asPayload(raw);
if (state.ready && state.ws) state.ws.send(payload);
else state.queue.push(payload); // buffer until the upstream socket opens
},
close(ws: ServerWebSocket<VaultWSData>) {
const state = upstreams.get(ws);
if (state) {
state.closed = true;
try {
state.ws?.close();
} catch {
/* already closed */
}
upstreams.delete(ws);
}
},
drain() {},
};
const PREFIX = '/api/vault';
// Serve-level upgrade for /api/vault/notifications/* WebSockets. The platform JWT rides the query
// (?access_token= for SignalR, or ?token=) and the session is validated in `open` (deferred). The device
// never sends a Vaultwarden token — we inject the stored one upstream.
//
// There was an isOriginAllowed gate here until 2026-08-13, removed with the rest of origin validation.
// It had defaulted to allow-everything, so it refused nothing on a real install.
export function upgradeVaultWs(req: Request, server: any): Response | undefined {
const url = new URL(req.url);
const platformToken = url.searchParams.get('access_token') || url.searchParams.get('token') || '';
if (!platformToken) return new Response('Unauthorized', { status: 401 });
const data: VaultWSData = {
provider: 'vault',
platformToken,
vaultWsPath: url.pathname.slice(PREFIX.length) + url.search,
vaultWsProtocol: req.headers.get('sec-websocket-protocol') ?? undefined,
};
const ok = server.upgrade(req, { data });
if (!ok) return new Response('Upgrade failed', { status: 500 });
return undefined;
}