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:
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>');
|
||||
@@ -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) });
|
||||
});
|
||||
@@ -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) });
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user