import { randomUUID, randomBytes } from 'node:crypto'; // Generates the iOS configuration profile that sets up CalDAV + CardDAV in one install, and holds it // against a single-use token until Safari fetches it. // // WHY A TOKEN AND NOT A SESSION. The profile is fetched by Safari, which has no platform JWT — the app // hands the URL to the OS browser and loses all control of the request. So the token IS the credential. // It is therefore minted long, used once, and dropped after five minutes, because the thing it protects // is a live DAV password sitting in a file on a public endpoint. A profile URL that stays valid is a // password that stays valid. // // WHY IN MEMORY. The profile contains the app password in PLAINTEXT — it has to, that is what makes the // install one-tap. `createDavAppPassword` promises the plaintext "is not stored", and writing the // profile to Postgres or to disk would quietly make that false. A process restart inside the five-minute // window costs the owner one extra tap; persisting the secret costs the guarantee. // // See docs/mobile-dav-provisioning.md §3 for the payload keys and where they come from. /* How long a provisioning link lives. Five minutes until 2026-08-27, which assumed the person minting the link is the person clicking it — true for a button in the UI, false for every other way this gets used. Handing a URL to someone who then has to pick up a phone, open Safari and type it took longer than that every single time, and the failure is indistinguishable from a broken profile: the link 404s and iOS says "Invalid Profile", which sends you looking at the plist rather than at the clock. That cost most of an evening. Thirty minutes, still single-use. The link embeds a live password, so this is a real trade — but the binding constraint is that it is claimed ONCE and then gone, not that the window is narrow. */ const TTL_MS = 30 * 60_000; // A restart clears everything anyway; this is only a guard against a script hammering the mint endpoint. const MAX_PENDING = 32; type PendingProfile = { body: Uint8Array; expiresAt: number }; const pending = new Map(); const sweep = () => { const now = Date.now(); for (const [token, entry] of pending) if (entry.expiresAt <= now) pending.delete(token); }; const escapeXml = (value: string) => value.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); type ProfileParams = { host: string; port: number; username: string; password: string; /** The DAV principal, e.g. `/dav/1/`. Both payloads point at it and let the client discover the rest. */ principalUrl: string; /** What the user sees as the account name in Settings. */ accountName: string; /** * Which accounts this profile creates. Both, unless something is wrong. * * ── Why this had to exist ── * * The comment below says one profile carrying both payloads sidesteps iOS folding CardDAV into an * existing CalDAV account with the same server and username. On iOS 26.6 it does not: installing the * two-payload profile produced ONE account offering Calendars and Reminders, with no Contacts toggle * anywhere and 460 cards that the phone never once requested. Verified from the server side — the * principal advertises `addressbook-home-set`, `/1/contacts/` is typed as an addressbook, and nothing * had ever asked for it. * * So CardDAV gets its own profile, with its own PayloadIdentifier, which iOS has nothing to merge into. */ kinds?: ('CalDAV' | 'CardDAV')[]; }; // One profile, both payloads. This is not a convenience: iOS keys accounts by server+username, so adding // CardDAV separately with the same pair gets folded into the existing CalDAV account and contacts // silently never appear. A single profile creates both accounts outright and sidesteps it. Detail in // docs/mobile-dav-provisioning.md §1.6, which cost an evening to learn. function buildPlist(params: ProfileParams): string { const { host, port, username, password, principalUrl, accountName, kinds = ['CalDAV', 'CardDAV'] } = params; // Stable per host, so re-running setup REPLACES the profile instead of stacking a second account, and // two Officer instances on one phone do not overwrite each other. const suffix = kinds.length === 1 ? `.${kinds[0]!.toLowerCase()}` : ''; const identifier = `dev.officer.dav.${host.replace(/[^a-zA-Z0-9]+/g, '-')}${suffix}`; const e = escapeXml; const account = (kind: 'CalDAV' | 'CardDAV', label: string) => ` PayloadTypecom.apple.${kind.toLowerCase()}.account PayloadVersion1 PayloadIdentifier${identifier}.${kind.toLowerCase()} PayloadUUID${randomUUID().toUpperCase()} PayloadDisplayName${e(label)} ${kind}AccountDescription${e(accountName)} ${kind}HostName${e(host)} ${kind}Port${port} ${kind}UseSSL ${kind}Username${e(username)} ${kind}Password${e(password)} ${kind}PrincipalURL${e(principalUrl)} `; return ` PayloadTypeConfiguration PayloadVersion1 PayloadIdentifier${identifier} PayloadUUID${randomUUID().toUpperCase()} PayloadDisplayName${e(accountName)} — Calendar & Contacts PayloadDescriptionAdds your Officer calendars and address book to this device. PayloadOrganizationOfficer PayloadRemovalDisallowed PayloadContent ${kinds.map((k) => account(k, k === 'CalDAV' ? 'Officer Calendar' : 'Officer Contacts')).join('\n')} `; } // Signing is OPT-IN and off by default, because this machine has no TLS certificate on it — TLS // terminates upstream. An unsigned profile installs exactly the same way; the only difference is that // the install screen says "Not Signed" in red instead of naming the signer. // // Point these at a cert whose chain iOS already trusts (a public CA — the TLS cert of the reverse proxy // works, iOS checks the trust chain and not the key usage) and profiles are signed from then on: // DAV_PROFILE_SIGN_CERT leaf certificate, PEM // DAV_PROFILE_SIGN_KEY its private key, PEM // DAV_PROFILE_SIGN_CHAIN intermediates, PEM (iOS ships roots, not intermediates — without this the // signature cannot be verified and shows as Not Verified) // // Signed at mint time, reading the cert from disk on every call, so a renewed certificate is picked up // with no restart and no renewal hook — the failure mode the mobile team flagged cannot happen here. // The remaining caveat is Apple's: a REPLACEMENT profile must be signed by the same identity as the one // it replaces, so after a certificate rotation a device may refuse to replace an older profile until the // old one is removed. Nothing detects that for you. /** * `OFFICERDEV_X ?? X`, which is the convention every other consumer follows. * * This read the bare names only. `.env` on a converted install prefixes everything — see * `servers/env-prefix.ts` — so on those machines the signing certificate could be configured correctly * and silently ignored, and the only symptom is a profile that says "Not Signed" for no visible reason. * The unprefixed name stays as the fallback, never the source. */ const env = (name: string) => process.env[`OFFICERDEV_${name}`] ?? process.env[name]; const signingConfig = () => { const cert = env('DAV_PROFILE_SIGN_CERT'); const key = env('DAV_PROFILE_SIGN_KEY'); if (!cert || !key) return null; return { cert, key, chain: env('DAV_PROFILE_SIGN_CHAIN') }; }; let warnedUnsigned = false; async function sign(plist: string): Promise { const config = signingConfig(); if (!config) { if (!warnedUnsigned) { warnedUnsigned = true; console.warn( '[dav] serving UNSIGNED iOS profiles — iOS will show "Not Signed" in red. Set DAV_PROFILE_SIGN_CERT/KEY to sign.', ); } return null; } // Piped through stdin/stdout rather than temp files: the plist holds a live password in plaintext and // there is no reason for it to touch the filesystem. `-outform der` and `-nodetach` are both // load-bearing — the default output is base64 S/MIME, which iOS will not parse, and without -nodetach // the file is a signature with no profile embedded in it. /* `-binary` IS NOT OPTIONAL, and its absence is why every signed profile was refused. Without it `openssl smime -sign` treats the input as TEXT: it canonicalises line endings and adds MIME headers before signing. The signature then verifies perfectly — `openssl smime -verify` reports "Verification successful" — while the bytes iOS unwraps are no longer the plist that was written. The device shows the profile, then refuses it with "Invalid Profile". Apple's own sample signs with `OpenSSL::PKCS7::BINARY` for the same reason. This was found and written down in `officer-oas/enroll.ts` on 2026-08-20, four wrong endings into a hunt that was never about the ending — and then repeated here on 2026-08-27 because this file was written without reading that one. */ const args = [ 'smime', '-sign', '-binary', '-signer', config.cert, '-inkey', config.key, '-outform', 'der', '-nodetach', ]; if (config.chain) args.push('-certfile', config.chain); args.push('-md', 'sha256'); const proc = Bun.spawn(['openssl', ...args], { stdin: new TextEncoder().encode(plist), stdout: 'pipe', stderr: 'pipe', }); const [out, err, code] = await Promise.all([ new Response(proc.stdout).arrayBuffer(), new Response(proc.stderr).text(), proc.exited, ]); if (code !== 0 || out.byteLength === 0) { // Deliberately not fatal. An unsigned profile still installs and still works; failing the whole // provisioning request over a cosmetic signature would be the worse outcome. console.error(`[dav] profile signing failed (exit ${code}), serving unsigned: ${err.trim().slice(0, 200)}`); return null; } return new Uint8Array(out); } export type StoredProfile = { url: string; /** The same profile at a URL a person can TYPE. See `shortCode`. */ shortUrl: string; code: string; expiresAt: string; signed: boolean; }; /* A code short enough to type into Safari's address bar. ── Why a long URL is not good enough ── The 43-character token cannot be typed, so it has to be tapped or pasted — and on a phone neither works. Officer runs from the Home Screen in a WEBVIEW, and a webview cannot hand a `.mobileconfig` to Settings: the download has nowhere to go and the screen does nothing. Every link the chat renders also gets `target="_blank"`, and a popup tab breaks the profile hand-off in Safari too. So the only path that reliably reaches iOS is a person typing a URL into real Safari, and that means it has to be short. ── Why this is not a weaker credential ── Crockford's alphabet minus the ambiguous letters, 8 characters: ~10^12 combinations. It is SINGLE USE and lives ten minutes, so an attacker gets one guess per attempt against a target that disappears when it is claimed or when the clock runs out. The long token still exists and still works; this is a second door to the same one-shot entry, not a replacement for it. */ const CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTVWXYZ23456789'; const CODE_TTL_MS = 10 * 60_000; const shortCode = (): string => { const bytes = randomBytes(8); return Array.from(bytes, (b) => CODE_ALPHABET[b % CODE_ALPHABET.length]).join(''); }; /** Build the profile and park it behind a one-shot token. Returns the URL Safari should be sent to. */ export async function stashIosProfile(params: ProfileParams, publicUrl: string): Promise { sweep(); if (pending.size >= MAX_PENDING) throw new Error('too many pending profiles'); const plist = buildPlist(params); const signed = await sign(plist); const body = signed ?? new TextEncoder().encode(plist); const token = randomBytes(32).toString('base64url'); const expiresAt = Date.now() + TTL_MS; pending.set(token, { body, expiresAt }); // The same profile behind a typeable code. Shorter-lived on purpose — see `shortCode`. const code = shortCode(); pending.set(code, { body, expiresAt: Date.now() + CODE_TTL_MS }); const base = publicUrl.replace(/\/$/, ''); return { url: `${base}/dav/provision/${token}.mobileconfig`, shortUrl: `${base}/dav/p/${code}`, code, expiresAt: new Date(expiresAt).toISOString(), signed: signed !== null, }; } /** Take the profile for this token, if it is still valid. Consumes it — a second fetch gets nothing. */ export function claimIosProfile(token: string): Uint8Array | null { sweep(); const entry = pending.get(token); if (!entry) { // SAID OUT LOUD, because the alternative is silence at the only moment it matters. // // A miss means expired, already used, or never existed, and the caller cannot be told which — telling // them would confirm a token once existed. But WE need to know: the response is a 404, iOS renders // that as "Invalid Profile", and the person reading it goes and inspects the plist. Nothing in the log // said a phone had even asked. One line here is the difference between "the link expired" and an // evening spent validating XML that was correct the whole time. console.warn(`[dav] ios profile claim MISSED (expired, already used, or never minted) — ${pending.size} pending`); return null; } pending.delete(token); console.log(`[dav] ios profile claimed — ${pending.size} still pending`); return entry.body; }