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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user