From 3f57cec551a2de6a8d8c97fd8adb9ba110743e8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 4 Aug 2026 14:20:52 +0000 Subject: [PATCH] one-tap ios dav provisioning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mints a dav app password, renders a configuration profile carrying both the caldav and carddav payloads, and parks it behind a single-use five-minute token that safari can fetch without a session. one profile with both payloads is not a convenience: ios keys accounts by server+username, so adding carddav separately gets folded into the existing caldav account and contacts silently never appear. the profile holds the password in plaintext, so it is held in memory only — persisting it would falsify createDavAppPassword's "not stored" guarantee. signing is opt-in via DAV_PROFILE_SIGN_CERT/_KEY/_CHAIN and off by default; this box has no tls certificate, tls terminates upstream. signed at mint time reading the cert from disk, so a renewal needs no restart and no hook. the download route is registered before the /dav mount because hono matches in registration order and the sync door's /* would otherwise demand http basic. Co-Authored-By: Claude Opus 5 --- docs/mobile-dav-provisioning.md | 90 ++++++++++---- src/servers/api/dav/ios-profile.ts | 189 +++++++++++++++++++++++++++++ src/servers/api/dav/router.ts | 50 ++++++++ src/servers/hono.ts | 23 ++++ 4 files changed, 331 insertions(+), 21 deletions(-) create mode 100644 src/servers/api/dav/ios-profile.ts diff --git a/docs/mobile-dav-provisioning.md b/docs/mobile-dav-provisioning.md index c727d697..335e6511 100644 --- a/docs/mobile-dav-provisioning.md +++ b/docs/mobile-dav-provisioning.md @@ -4,10 +4,11 @@ **Goal:** the user opens the Officer app, taps one button, and their phone's native Calendar and Contacts apps start syncing with their Officer server. No typing a server URL. No typing a password. -**Status of this document:** the server side described in §1 is built, deployed and verified against a -real iPhone. Everything in §3 (iOS) and §4 (Android) is a specification for work that has **not** been -written yet — the payload keys, intent names and install flows are researched and cited, not -implemented. Where something is unverified I say so explicitly rather than rounding it up to fact. +**Status of this document:** the server side is built, deployed and verified — §1 against a real iPhone, +and §3.4's provisioning endpoint end to end on 2026-08-04. The **client** work in §3 (iOS) and §4 +(Android) is still a specification: the payload keys, intent names and install flows are researched and +cited, not implemented. Where something is unverified I say so explicitly rather than rounding it up to +fact. --- @@ -96,7 +97,16 @@ Principal https:///dav// A collection https:///dav/// ``` -`` is the numeric account id — `1` on a single-user instance, which every Officer instance is. +`` **is the platform user id, by construction** — the same integer `/auth/me` returns. They +cannot diverge: `sync-router.ts` sets `X-Officer-User: String(userId)` straight from the app-password +row, the sidecar forwards it to Radicale as `X-Remote-User`, and Radicale's storage tree is literally +`//`. There is no mapping table to get out of step. On a single-user instance — which every +Officer instance is — that is `1`. Deriving it from `/auth/me` is safe; so is deriving it from the +collection paths, which is why both work today. + +**A collection cannot live outside `/dav//`.** Two independent guards: the sidecar rejects any +`collection` outside that prefix, and Radicale runs `rights type = owner_only`. + Collections on the reference deployment are `personal`, `work` (calendars) and `contacts` (address book), but **these are user-created and you must not hardcode them.** Discover them: @@ -261,24 +271,39 @@ account, which is what you want when the user re-runs setup. ### 3.4 Generate it server-side, not in the app -The profile embeds a password that only the server can mint. Build it on the server. Suggested new -endpoint — **this does not exist yet; it is yours (or ours) to write**: +The profile embeds a password that only the server can mint, so it is built on the server. +**Implemented and verified live on 2026-08-04** (`src/servers/api/dav/ios-profile.ts`): ``` POST /api/dav/provision/ios body: { deviceLabel: string } behind userMiddleware 1. mints an app password labelled deviceLabel - 2. renders the plist - 3. signs it - 4. stores it against a single-use, short-lived token - → { url: "https:///dav/provision/.mobileconfig", expiresAt } + 2. renders one plist carrying BOTH payloads, PayloadIdentifier stable per host + 3. signs it, if signing is configured (§3.5) — unsigned otherwise, never a hard failure + 4. holds it in memory against a single-use token + → 200 { url: "https:///dav/provision/.mobileconfig", expiresAt, signed } GET /dav/provision/.mobileconfig no session auth (Safari has none) — the token IS the auth - single use, ≤5 min TTL, deleted on first fetch + single use, 5 min TTL, deleted on first fetch; expired/used/never-existed all answer 404 Content-Type: application/x-apple-aspen-config ``` +`signed` is a boolean the app can surface — it tells you in advance whether the user is about to see a +red **Not Signed** on the install screen. + +Errors are all `500` with a message, and all are misconfiguration rather than anything the app did: +`PUBLIC_URL` unset, unparseable, or not `https`. A missing `deviceLabel` is `400`. + +**The profile is never persisted** — not to Postgres, not to disk. It holds the app password in +plaintext, and `createDavAppPassword` promises that plaintext is not stored. A server restart inside +the five-minute window invalidates a pending URL; the app should treat a 404 as "mint another", which +costs one extra tap. + +**Registration order matters** if you ever touch `hono.ts`: this GET must be registered _before_ +`honoServer.route('/dav', davSyncRouter)`, or the sync door's `/*` catch-all answers it with an HTTP +Basic challenge that Safari has no credential for. + The `Content-Type` is mandatory — iOS identifies a profile by MIME type, and serving it as `application/octet-stream` or `text/xml` gets you a downloaded file the OS ignores. @@ -289,16 +314,35 @@ stays valid**, sitting on a public HTTPS endpoint with no authentication. Apple's requirement: "place the XML property list in a DER-encoded, CMS Signed Data structure." +**Signing is opt-in and currently OFF on the reference deployment, because there is no TLS certificate +on this box — TLS terminates on an upstream VPS that proxies in.** Profiles are served unsigned; they +install identically, the user just sees a red **Not Signed**. Three env vars turn it on, no code change: + +``` +DAV_PROFILE_SIGN_CERT leaf certificate, PEM +DAV_PROFILE_SIGN_KEY its private key, PEM +DAV_PROFILE_SIGN_CHAIN intermediates, PEM (optional but effectively required — see below) +``` + +Point them at whatever cert the box ends up holding once TLS moves local. The equivalent by hand is: + ```bash openssl smime -sign \ -in profile.mobileconfig \ -out profile-signed.mobileconfig \ - -signer /etc/letsencrypt/live//cert.pem \ - -inkey /etc/letsencrypt/live//privkey.pem \ - -certfile /etc/letsencrypt/live//chain.pem \ + -signer $DAV_PROFILE_SIGN_CERT \ + -inkey $DAV_PROFILE_SIGN_KEY \ + -certfile $DAV_PROFILE_SIGN_CHAIN \ -outform der -nodetach -md sha256 ``` +**There is no renewal hook and none is needed.** Signing happens at mint time and reads the certificate +off disk on every call, so a renewed cert is picked up on the next provision with no restart and nothing +to remember. If signing fails for any reason it is logged (`[dav] profile signing failed …`) and the +profile is served unsigned rather than failing the request — the response's `signed: false` is how the +app finds out. The one caveat below that this does _not_ solve is Apple's own: a **replacement** profile +must be signed by the same identity as the one it replaces. + `-outform der` and `-nodetach` are both load-bearing: the default output is S/MIME (base64 + MIME headers), which iOS will not parse, and without `-nodetach` the file contains a signature with no embedded profile. `-certfile` matters because iOS ships roots, not intermediates. @@ -312,12 +356,13 @@ so this is well-corroborated practice rather than documented policy. Two real ri 1. **Use an RSA cert (`--key-type rsa`).** There is an unresolved report of ECDSA P-256-signed profiles showing "Unverified" where a byte-identical RSA one shows verified. Unconfirmed, but the RSA path is the one that is definitively known to work. -2. **90-day expiry.** There is no timestamping; iOS evaluates the signing cert at install time. You - must re-sign on every certificate renewal, or new installs show "Not Verified". Also: Apple rejects - a _replacement_ profile signed with a different identity, so plan the renewal, don't improvise it. +2. **90-day expiry.** There is no timestamping; iOS evaluates the signing cert at install time. Every + profile being signed fresh at mint time handles the renewal itself. What it does not handle: Apple + rejects a _replacement_ profile signed with a different identity, so after a rotation a device may + refuse to replace an older profile until the old one is removed. Nothing detects that for you. Unsigned works too — the install flow is identical, the user just sees a red **Not Signed**. Ship -signed. +signed once there is a certificate on the box to sign with. ### 3.6 The install flow — and its hard limits @@ -517,8 +562,11 @@ You get no success callback on either platform. Options, best first: ## 7. Suggested order of work -1. Server: `POST /api/dav/provision/ios` + the token-gated `.mobileconfig` endpoint (§3.4), signing - wired into the certbot renewal hook (§3.5). This is the only new backend work either platform needs. +1. ~~Server: `POST /api/dav/provision/ios` + the token-gated `.mobileconfig` endpoint (§3.4).~~ **Done + 2026-08-04**, verified end to end: 200 with a URL, correct MIME type, both payloads present, second + fetch 404, and the embedded password authenticates a `PROPFIND /dav/1/` (207) until it is revoked. + Signing is opt-in and off (§3.5) — no renewal hook exists or is needed. This was the only new backend + work either platform needed. 2. Android: the native intent module + the setup screen. Smallest, ships first, and it validates the whole shape of the feature. 3. iOS: the setup screen with real device screenshots for every step in §3.6. diff --git a/src/servers/api/dav/ios-profile.ts b/src/servers/api/dav/ios-profile.ts new file mode 100644 index 00000000..cdaae05a --- /dev/null +++ b/src/servers/api/dav/ios-profile.ts @@ -0,0 +1,189 @@ +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. + +const TTL_MS = 5 * 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; +}; + +// 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 } = 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 identifier = `dev.officer.dav.${host.replace(/[^a-zA-Z0-9]+/g, '-')}`; + 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 + PayloadScopeUser + PayloadRemovalDisallowed + PayloadContent + +${account('CalDAV', 'Officer Calendar')} +${account('CardDAV', 'Officer Contacts')} + + + +`; +} + +// 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. +const signingConfig = () => { + const cert = process.env.DAV_PROFILE_SIGN_CERT; + const key = process.env.DAV_PROFILE_SIGN_KEY; + if (!cert || !key) return null; + return { cert, key, chain: process.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. + const args = ['smime', '-sign', '-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; expiresAt: string; signed: boolean }; + +/** 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 }); + + return { + url: `${publicUrl.replace(/\/$/, '')}/dav/provision/${token}.mobileconfig`, + 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) return null; + pending.delete(token); + return entry.body; +} diff --git a/src/servers/api/dav/router.ts b/src/servers/api/dav/router.ts index 651eeaed..02b56924 100644 --- a/src/servers/api/dav/router.ts +++ b/src/servers/api/dav/router.ts @@ -1,6 +1,7 @@ import { createRouter } from '../../create-router'; import { listDavAppPasswords, createDavAppPassword, revokeDavAppPassword, deleteDavAppPassword } from 'officerdb'; import * as errors from '../../custom-errors'; +import { stashIosProfile } from './ios-profile'; // Management of DAV app passwords, for Officer's own UI. Behind userMiddleware like everything else // under /api — this is the owner administering their devices from a logged-in browser, which is a @@ -28,6 +29,55 @@ davRouter.post('/passwords', async (ctx) => { return ctx.json({ entry, password, username: user.email }); }); +// One-tap iOS setup: mint a credential, wrap it in a configuration profile, hand back a URL for the app +// to open in Safari. See docs/mobile-dav-provisioning.md §3, and ios-profile.ts for why the profile is +// held in memory behind a one-shot token rather than stored. +// +// This is the only endpoint that returns a route to a live password instead of the password itself. It +// exists because the alternative is the owner typing a 25-character secret into a phone keyboard twice. +davRouter.post('/provision/ios', async (ctx) => { + const user = ctx.get('user'); + const body = ctx.get('body') as { deviceLabel?: string } | undefined; + const deviceLabel = body?.deviceLabel?.trim(); + if (!deviceLabel) throw errors.BAD_REQUEST('deviceLabel is required'); + + // The profile has to name a host, and PUBLIC_URL is the only place that knows the one a phone can + // actually reach — the request's own Host header is whatever the reverse proxy forwarded. + const publicUrl = process.env.PUBLIC_URL; + if (!publicUrl) throw errors.INTERNAL_SERVER_ERROR('PUBLIC_URL is not set; cannot build a profile'); + + let url: URL; + try { + url = new URL(publicUrl); + } catch { + throw errors.INTERNAL_SERVER_ERROR('PUBLIC_URL is not a valid URL'); + } + if (url.protocol !== 'https:') { + // CalDAVUseSSL is hardcoded true in the payload, so an http PUBLIC_URL would produce a profile that + // cannot work. Better to say so than to ship a phone an account that silently never syncs. + throw errors.INTERNAL_SERVER_ERROR('PUBLIC_URL must be https to provision a device'); + } + + const { entry, password } = await createDavAppPassword(user.id, deviceLabel); + + const stored = await stashIosProfile( + { + host: url.hostname, + port: Number(url.port) || 443, + username: user.email, + password, + // The principal, not a collection: iOS discovers every calendar and address book under it, so a + // collection the owner adds later appears without re-provisioning. + principalUrl: `/dav/${user.id}/`, + accountName: 'Officer', + }, + url.origin, + ); + + console.log(`[dav] provisioned ios profile for "${deviceLabel}" (password #${entry.id}, signed=${stored.signed})`); + return ctx.json(stored); +}); + davRouter.post('/passwords/:id/revoke', async (ctx) => { const user = ctx.get('user'); const id = Number(ctx.req.param('id')); diff --git a/src/servers/hono.ts b/src/servers/hono.ts index a9455934..4caa81a1 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -34,6 +34,7 @@ import { caldavRouter } from './api/dav/sidecar-server'; import { memosRouter } from './api/memos/router'; import { davSyncRouter } from './api/dav/sync-router'; import { davRouter } from './api/dav/router'; +import { claimIosProfile } from './api/dav/ios-profile'; import { notifyRouter } from './api/notify/router'; import { systemMonitorRouter } from './api/system-monitor/system-monitor'; import { activityRouter } from './api/activity/router'; @@ -104,6 +105,28 @@ honoServer.get('/api/integrations/google/callback', googleCallbackHandler); // DAVx5, iOS and Thunderbird authenticate with HTTP Basic on every request and have nowhere to put a // platform JWT, so userMiddleware would 401 them. The credential is a scoped DAV app password; see // api/dav/sync-router.ts. +// The iOS profile download, registered BEFORE the /dav mount below because hono matches in registration +// order and davSyncRouter's `/*` would otherwise demand HTTP Basic for it. Safari has no credential to +// offer — it was handed a URL by the app and nothing else — so the one-shot token in the path IS the +// authentication. Minted by POST /api/dav/provision/ios; see api/dav/ios-profile.ts. +honoServer.get('/dav/provision/:file', (ctx) => { + const file = ctx.req.param('file'); + const token = file.endsWith('.mobileconfig') ? file.slice(0, -'.mobileconfig'.length) : null; + const body = token ? claimIosProfile(token) : null; + // Expired, already used, or never existed — all the same 404. There is nothing useful to tell a + // caller who has the wrong token, and distinguishing the cases would confirm that a token once existed. + if (!body) return ctx.text('not found', 404); + + return new Response(body as unknown as BodyInit, { + headers: { + // Mandatory. iOS identifies a configuration profile by MIME type; served as octet-stream or + // text/xml the file downloads and the OS does nothing with it. + 'Content-Type': 'application/x-apple-aspen-config', + 'Cache-Control': 'no-store', + }, + }); +}); + honoServer.route('/dav', davSyncRouter); // Autodiscovery. This is most of what makes adding an account on a phone feel transparent instead of