one-tap ios dav provisioning

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 14:20:52 +00:00
co-authored by Claude Opus 5
parent 7b16e88b2f
commit 3f57cec551
4 changed files with 331 additions and 21 deletions
+69 -21
View File
@@ -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://<host>/dav/<userId>/
A collection https://<host>/dav/<userId>/<collection>/
```
`<userId>` is the numeric account id — `1` on a single-user instance, which every Officer instance is.
`<userId>` **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
`/<that value>/`. 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/<userId>/`.** 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://<host>/dav/provision/<opaque-token>.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://<host>/dav/provision/<token>.mobileconfig", expiresAt, signed }
GET /dav/provision/<token>.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/<domain>/cert.pem \
-inkey /etc/letsencrypt/live/<domain>/privkey.pem \
-certfile /etc/letsencrypt/live/<domain>/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.
+189
View File
@@ -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<string, PendingProfile>();
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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) => ` <dict>
<key>PayloadType</key><string>com.apple.${kind.toLowerCase()}.account</string>
<key>PayloadVersion</key><integer>1</integer>
<key>PayloadIdentifier</key><string>${identifier}.${kind.toLowerCase()}</string>
<key>PayloadUUID</key><string>${randomUUID().toUpperCase()}</string>
<key>PayloadDisplayName</key><string>${e(label)}</string>
<key>${kind}AccountDescription</key><string>${e(accountName)}</string>
<key>${kind}HostName</key><string>${e(host)}</string>
<key>${kind}Port</key><integer>${port}</integer>
<key>${kind}UseSSL</key><true/>
<key>${kind}Username</key><string>${e(username)}</string>
<key>${kind}Password</key><string>${e(password)}</string>
<key>${kind}PrincipalURL</key><string>${e(principalUrl)}</string>
</dict>`;
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadType</key><string>Configuration</string>
<key>PayloadVersion</key><integer>1</integer>
<key>PayloadIdentifier</key><string>${identifier}</string>
<key>PayloadUUID</key><string>${randomUUID().toUpperCase()}</string>
<key>PayloadDisplayName</key><string>${e(accountName)} — Calendar &amp; Contacts</string>
<key>PayloadDescription</key><string>Adds your Officer calendars and address book to this device.</string>
<key>PayloadOrganization</key><string>Officer</string>
<key>PayloadScope</key><string>User</string>
<key>PayloadRemovalDisallowed</key><false/>
<key>PayloadContent</key>
<array>
${account('CalDAV', 'Officer Calendar')}
${account('CardDAV', 'Officer Contacts')}
</array>
</dict>
</plist>
`;
}
// 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<Uint8Array | null> {
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<StoredProfile> {
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;
}
+50
View File
@@ -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'));
+23
View File
@@ -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