import type { Context } from 'hono'; import { createRouter } from '@@/create-router'; import { looksLikeApiKey, resolveApiKey } from '@@/auth-token'; import { isApiRequestAllowed } from '@@/permissions/authorize'; import { mountPrefix } from '@@/plugins/manifest'; import { manifest } from '../manifest'; import { getUserByEmail } from 'officerdb'; import { verifyDavAppPassword } from '../db/queries'; import { getCaldavUrl } from './sidecar-server'; // The DAV door: `/dav/*`, mounted TOP-LEVEL, outside protectedRouter. // // WHY IT IS NOT UNDER /api AND NOT BEHIND userMiddleware. DAVx5, iOS Calendar, macOS and Thunderbird // speak CalDAV over HTTPS with HTTP Basic auth on every single request. They cannot log into Officer, // cannot hold a 30-day JWT, cannot refresh one and cannot answer a passkey challenge. This is the same // situation `/api/vault` is in — "the Bitwarden client carries its own bearer token, not a platform // session JWT, so userMiddleware would 401 it" (hono.ts) — with a different credential type. // // The credential is a DAV app password (`dav_app_passwords`), scoped to this mount and nothing else. // An app password is NOT a session: userMiddleware must never learn to accept one, or a device that // syncs a calendar would also reach the wallet. // // Everything is forwarded verbatim to the caldav sidecar, which forwards it to Radicale. The platform // authenticates and moves bytes; it does not parse a single line of iCalendar. That restraint is the // same one create-proxy.ts documents, and for the same reason. export const davSyncRouter = createRouter(); const unauthorized = () => new Response('Unauthorized', { status: 401, // Without this header a DAV client will not prompt for credentials at all — it just fails. The // realm string is what the phone shows in its password dialog. headers: { 'WWW-Authenticate': 'Basic realm="Officer DAV", charset="UTF-8"' }, }); type BasicCreds = { username: string; password: string }; function parseBasic(header: string | undefined): BasicCreds | null { if (!header?.startsWith('Basic ')) return null; let decoded: string; try { decoded = Buffer.from(header.slice(6), 'base64').toString('utf8'); } catch { return null; } // Split on the FIRST colon only — a generated password never contains one, but an email local-part // legally can, and splitting greedily would break those accounts in a way that looks like a wrong // password. const at = decoded.indexOf(':'); if (at < 0) return null; return { username: decoded.slice(0, at), password: decoded.slice(at + 1) }; } // Same list the sidecar forwards, and for the same reason: `Depth` decides whether PROPFIND returns a // collection, its children or the whole tree, so dropping it makes every calendar look empty. const REQUEST_HEADERS = [ 'depth', 'content-type', 'content-length', 'if', 'if-match', 'if-none-match', 'destination', 'overwrite', 'lock-token', 'timeout', 'prefer', 'user-agent', ] as const; const RESPONSE_HEADERS = [ 'content-type', 'dav', 'allow', 'etag', 'last-modified', 'location', 'lock-token', 'preference-applied', 'vary', ] as const; /* DAV methods, sorted into read and write. `decideApiRequest` reasons in HTTP terms — a safe method needs a read grant, anything else needs write — and DAV's vocabulary is not in that list at all. Left unmapped, PROPFIND and REPORT would count as writes, and a member granted READ on the calendar could not so much as list it. Anything not named here is treated as a WRITE. That is the safe direction for a default: a DAV extension nobody here has heard of asking for read access is a small annoyance, and one silently granted write access is not. */ const DAV_READ_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'PROPFIND', 'REPORT']); /** * The other way to authenticate here: an agent's own session credential. * * ── Why this exists ── * * The owner asked for any agent they are chatting with to be able to add an event or a contact. Doing * that through this door needs a credential, and every alternative meant creating a NEW secret and then * solving where it lives — a file on disk, the secret store, one per commander. Each of those is a * password to distribute, rotate and eventually leak. * * `$OFFICER_AGENT_KEY` is already in every agent's environment, already bound to one user, already * revoked when the session ends. Accepting it here adds no secret to the world. * * ── Why it cannot reach another account ── * * The key IS the identity — `resolveApiKey` returns whose it is, and that id becomes `X-Remote-User`. * There is no field an agent could change to address someone else: the URL is not consulted, and Radicale * refuses a principal that is not the one in the header. The username in the Basic header must also match * the key's owner, so a request cannot even LOOK like it is acting for somebody else. * * ── Why the permission check is the platform's own ── * * `isApiRequestAllowed` against this plugin's API prefix, so the answer is exactly what the permission * gate would give for the same credential on the same plugin: role grants, the credential's own allow * list, and its excluded kinds. Reimplementing that here would be a second copy of the rule, and the * second copy is the one that stops being updated. A key narrowed away from `calendar` is refused here * for the same reason it would be refused at `/api/caldav`. */ async function agentKeyUser(ctx: Context, user: { id: number }, secret: string): Promise { if (!looksLikeApiKey(secret)) return null; const identity = await resolveApiKey(secret); if (!identity) return null; // The credential decides who this is; the username has to agree with it rather than select it. if (identity.id !== user.id) { console.warn(`[dav] auth failed: api key belongs to user ${identity.id}, not ${user.id}`); return null; } const method = DAV_READ_METHODS.has(ctx.req.method.toUpperCase()) ? 'GET' : 'PUT'; const verdict = await isApiRequestAllowed(identity.id, method, `/api${mountPrefix({ appName: 'caldav', manifest })}/`, identity.scope); if (!verdict.allowed) { console.warn(`[dav] api key refused for user ${identity.id}: ${verdict.reason ?? 'not permitted'}`); return null; } console.log(`[dav] authenticated user ${identity.id} by session credential (${ctx.req.method})`); return identity.id; } davSyncRouter.all('/*', async (ctx) => { const creds = parseBasic(ctx.req.header('authorization')); if (!creds) return unauthorized(); // A credential-less request is the normal opening move — the client is asking to be challenged — so it // is not worth a line. A credential that is present and wrong is the interesting case, and it is // indistinguishable from the normal one at the client end: both just say "password incorrect". const user = await getUserByEmail(creds.username); if (!user) { console.warn(`[dav] auth failed: no such user ${creds.username}`); return unauthorized(); } const userId = (await verifyDavAppPassword(user.id, creds.password)) ?? (await agentKeyUser(ctx, user, creds.password)); if (!userId) { console.warn(`[dav] auth failed: bad credential for ${creds.username}`); return unauthorized(); } const base = getCaldavUrl(); if (!base) return new Response('caldav sidecar not available', { status: 503 }); const url = new URL(ctx.req.url); // The sidecar mounts DAV at /dav too, so the prefix is preserved rather than stripped — Radicale // generates absolute hrefs in its multistatus responses, and if the path the client sees differs // from the path the server thinks it is at, every href points somewhere that 404s. const target = `${base}${url.pathname}${url.search}`; const headers: Record = {}; for (const name of REQUEST_HEADERS) { const value = ctx.req.header(name); if (value) headers[name] = value; } headers['X-Officer-User'] = String(userId); const method = ctx.req.method; const hasBody = method !== 'GET' && method !== 'HEAD'; let upstream: Response; try { upstream = await fetch(target, { method, headers, body: hasBody ? await ctx.req.arrayBuffer() : undefined, }); } catch (err) { console.error('[dav] proxy fetch failed', { path: url.pathname, error: String(err) }); return new Response('caldav sidecar unreachable', { status: 502 }); } const out = new Headers(); for (const name of RESPONSE_HEADERS) { const value = upstream.headers.get(name); if (value) out.set(name, value); } // One line per request, with the client's own name for itself. A DAV client tells you almost nothing // when it fails — iOS in particular reports every setup failure as "Cannot connect using SSL" — so the // only way to know whether a phone ever asked for an address book is to record that it did. Cheap: // a sync is a handful of requests, and a body is never logged. console.log( `[dav] ${method} ${url.pathname} -> ${upstream.status} (user=${userId}, ua=${(ctx.req.header('user-agent') ?? '-').slice(0, 60)})`, ); return new Response(upstream.body, { status: upstream.status, headers: out }); });