Files
platform/docs/mobile-dav-provisioning.md
pastilhasandClaude Opus 5 3f57cec551 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>
2026-08-04 14:20:52 +00:00

606 lines
32 KiB
Markdown

# One-tap calendar & contacts setup — handoff for the mobile developers
**Audience:** whoever builds the Officer iOS and Android apps.
**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 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.
---
## 0. The honest summary, before you read 4000 words
There is no "one button" on either platform, if "one button" means silent. Neither iOS nor Android
lets a normal app create a system calendar/contacts account without the user seeing something.
What is achievable:
| | Best case | Taps after your button |
| ----------- | -------------------------------------------------------------------- | ----------------------------------------------------------- |
| **iOS** | Serve a signed `.mobileconfig`, open it in Safari | ~5 (Allow → Settings → Install → passcode → Install → Done) |
| **Android** | Fire an explicit Intent into DAVx⁵ with URL + credentials pre-filled | ~3 (Next → pick collections → Create) |
Both are a large improvement over the status quo, which is the user hand-typing a URL, an email and a
32-character generated password into a settings screen, on a phone keyboard, twice (once for
calendar, once for contacts). That is what we are eliminating.
Read §5 before you ship either one — the credential handling is the part that can actually hurt.
---
## 1. The server contract (built, live, verified)
Base URL: the Officer instance's `PUBLIC_URL`. On the reference deployment that is
`https://officer.pastilhas.dev`. **Do not hardcode it** — the app already knows its server; use the
same base it uses for the REST API.
### 1.1 Two doors, deliberately different
Officer exposes DAV twice, and confusing them is the fastest way to waste a day:
| Path | Auth | Who uses it |
| ------------------------ | ---------------------------- | -------------------------------------------------- |
| `/dav/*` | HTTP Basic, on every request | Phones, Thunderbird, any CalDAV/CardDAV client |
| `/api/caldav/_officer/*` | Platform session JWT | Officer's own web UI (returns JSON, not iCalendar) |
| `/api/dav/passwords` | Platform session JWT | Managing the credentials the first row uses |
`/dav` is mounted **top level**, outside the auth middleware, because a CalDAV client cannot hold a
30-day JWT, cannot refresh one, and cannot answer a passkey challenge. It authenticates with Basic on
literally every request. See `src/servers/api/dav/sync-router.ts` for the implementation and the
reasoning.
The mobile app itself talks to the **third** row — the JSON door — to mint the credential. It never
speaks DAV.
### 1.2 The credential model
- **Username** = the account's email address. (Officer is single-user; there is exactly one.)
- **Password** = a **DAV app password**, not the login password.
DAV app passwords are argon2-hashed at rest, scoped to `/dav` and nothing else, and **the plaintext is
returned exactly once, at creation, and is not readable again by anything**. There is deliberately no
"show it to me again" endpoint. If it is lost, revoke the row and mint another — that is cheaper than
any design where the secret can be read back.
This matters for your flow: **you cannot build a "re-send my config" button that reuses an existing
password.** Every provisioning action mints a new one.
### 1.3 App-password API
All four are behind the normal session auth — same `Authorization: Bearer <jwt>` your app already
sends. Source: `src/servers/api/dav/router.ts`.
```
GET /api/dav/passwords
→ { passwords: [{ id, label, createdAt, lastUsedAt, revokedAt }, ...] }
POST /api/dav/passwords body: { label: string } (required, non-empty)
→ { entry: {...}, password: "<plaintext, once>", username: "<owner email>" }
POST /api/dav/passwords/:id/revoke → { ok: true }
DELETE /api/dav/passwords/:id → { ok: true }
```
**Label it with the device.** `"Pixel 9"`, `"iPhone 15 — Officer app"`. The label is the only thing
that lets the owner revoke the right one later from the web UI
(Settings → Integrations → DAV app passwords). A label of `"mobile"` on four devices is useless.
### 1.4 URLs the clients need
```
Base / discovery root https://<host>/dav/
Principal https://<host>/dav/<userId>/
A collection https://<host>/dav/<userId>/<collection>/
```
`<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:
- **For your own UI** (e.g. showing the user what will be synced): `GET /api/caldav/_officer/collections`
returns `[{ path, displayName, kind: 'calendar' | 'addressbook', color }]`.
- **For the clients**: don't. Give DAVx⁵ and iOS the _base_ URL and let them do RFC 6764 discovery.
That is what it is for.
### 1.5 Autodiscovery — works, with one caveat
```
GET|PROPFIND /.well-known/caldav → 301 → /dav/
GET|PROPFIND /.well-known/carddav → 301 → /dav/
OPTIONS /dav/ → DAV: 1, 2, 3, calendar-access, addressbook, extended-mkcol
```
Registered with `.all`, not `.get` — RFC 6764 §6 has the client probe the well-known URI with the
method it actually intends to use, and iOS sends `PROPFIND`. (These were GET-only until 2026-08-03 and
answered 404 to every real client while looking perfectly healthy in a browser. Test with the method
the client uses.)
CORS is deliberately **not** applied to `/dav` or the well-known paths, because Hono's `cors()`
answers every `OPTIONS` itself as a preflight — 204, no `DAV:` header — and never reaches the route
beneath. iOS refuses an account whose server does not advertise `calendar-access`, and reports the
failure as _"Cannot connect using SSL"_, which is a message about TLS for a problem that has nothing to
do with TLS. Don't reintroduce it.
### 1.6 The iOS account-merge trap (verified the hard way)
**iOS keys accounts by `server + username`.** If a CalDAV account already exists for
`officer.example.com` / `owner@example.com`, adding a _CardDAV_ account with the same pair is silently
folded into the existing calendar account — no error, no new account, contacts simply never appear.
Manual workaround is to give the CardDAV account the explicit collection URL
(`https://host/dav/1/contacts/`) so the pair differs. **A configuration profile sidesteps this
entirely** — one profile containing both a CalDAV and a CardDAV payload creates both accounts
correctly. That is a real argument for the profile approach over any "deep link into Settings" idea.
### 1.7 Debugging
Every proxied DAV request is logged server-side:
```
pm2 logs officer | grep '\[dav\]'
[dav] PROPFIND /dav/1/contacts/ -> 207 (user=1, ua=iOS/18.5 (22F76) accountsd/1.0)
```
Failed auth logs a warning (`no such user X` / `bad app password for X`). A request with no
credentials at all is not logged — that is the normal opening move of every client. If you are
debugging a phone that "won't connect", ask for this log first; the `ua` string tells you which
daemon on the phone is (or is not) talking.
---
## 2. What you're building, in both apps
A screen — call it **Sync calendar & contacts** — that:
1. Explains in one sentence what will happen.
2. Has one primary button.
3. On tap: mints an app password labelled with the device, hands it to the platform mechanism, and
walks the user through the residual taps with real screenshots.
4. Afterwards, shows sync status and a **Remove / revoke** action.
Point 4 is not optional. You are creating a long-lived credential on the user's device; the app that
created it is the only reasonable place to destroy it.
---
## 3. iOS — configuration profile
### 3.1 Mechanism
Apple's configuration profile format (`.mobileconfig`) is a signed XML plist. Two payload types matter,
and Apple's own documentation confirms **both allow manual (non-MDM) installation on iOS, with no
supervision and no user-approved MDM required**:
- `com.apple.caldav.account` — https://developer.apple.com/documentation/devicemanagement/caldav
- `com.apple.carddav.account` — https://developer.apple.com/documentation/devicemanagement/carddav
### 3.2 Exact payload keys
Verified against Apple's device-management reference. Note `HostName`, not `AccountHostName`;
capital `N` in `HostName`; lowercase `n` in `Username`.
**CalDAV** (`com.apple.caldav.account`):
| Key | Type | Required |
| -------------------------- | ------- | ----------------------------------------------------- |
| `CalDAVHostName` | string | **yes** |
| `CalDAVUsername` | string | required for non-interactive install |
| `CalDAVPassword` | string | no ("only use this in encrypted profiles" — see §5.2) |
| `CalDAVPort` | integer | no |
| `CalDAVUseSSL` | boolean | no, defaults `true` |
| `CalDAVPrincipalURL` | string | no — "the base URL to the user's calendar" |
| `CalDAVAccountDescription` | string | no — this is the name the user sees |
**CardDAV** (`com.apple.carddav.account`): identical shape — `CardDAVHostName` (required),
`CardDAVUsername`, `CardDAVPassword`, `CardDAVPort`, `CardDAVUseSSL`, `CardDAVPrincipalURL`,
`CardDAVAccountDescription`.
Top level: `PayloadType` must be the literal string `Configuration`; `PayloadVersion` must be integer
`1` (it is the _format_ version, not yours); `PayloadIdentifier` (reverse-DNS — this is what decides
replace-vs-add on reinstall); `PayloadUUID`; `PayloadContent` (array). Each entry in `PayloadContent`
carries its own `PayloadType`/`PayloadVersion`/`PayloadIdentifier`/`PayloadUUID`, and its identifier
must be unique within the profile.
### 3.3 Template
```xml
<?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>dev.officer.dav</string>
<key>PayloadUUID</key> <string><!-- fresh UUID per issued profile --></string>
<key>PayloadDisplayName</key> <string>Officer Calendar &amp; Contacts</string>
<key>PayloadDescription</key> <string>Adds your Officer calendars and address book.</string>
<key>PayloadOrganization</key> <string>Officer</string>
<key>PayloadScope</key> <string>User</string>
<key>PayloadRemovalDisallowed</key> <false/>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadType</key> <string>com.apple.caldav.account</string>
<key>PayloadVersion</key> <integer>1</integer>
<key>PayloadIdentifier</key> <string>dev.officer.dav.caldav</string>
<key>PayloadUUID</key> <string><!-- fresh UUID --></string>
<key>PayloadDisplayName</key><string>Officer Calendar</string>
<key>CalDAVAccountDescription</key><string>Officer</string>
<key>CalDAVHostName</key> <string>officer.example.com</string>
<key>CalDAVPort</key> <integer>443</integer>
<key>CalDAVUseSSL</key> <true/>
<key>CalDAVUsername</key> <string>owner@example.com</string>
<key>CalDAVPassword</key> <string><!-- minted app password --></string>
<key>CalDAVPrincipalURL</key><string>/dav/1/</string>
</dict>
<dict>
<key>PayloadType</key> <string>com.apple.carddav.account</string>
<key>PayloadVersion</key> <integer>1</integer>
<key>PayloadIdentifier</key> <string>dev.officer.dav.carddav</string>
<key>PayloadUUID</key> <string><!-- fresh UUID --></string>
<key>PayloadDisplayName</key><string>Officer Contacts</string>
<key>CardDAVAccountDescription</key><string>Officer</string>
<key>CardDAVHostName</key> <string>officer.example.com</string>
<key>CardDAVPort</key> <integer>443</integer>
<key>CardDAVUseSSL</key> <true/>
<key>CardDAVUsername</key> <string>owner@example.com</string>
<key>CardDAVPassword</key> <string><!-- minted app password --></string>
<key>CardDAVPrincipalURL</key><string>/dav/1/</string>
</dict>
</array>
</dict>
</plist>
```
`PayloadUUID`s must be freshly generated per issued profile. `PayloadIdentifier` should stay **stable**
— reinstalling with the same identifier replaces the previous profile rather than stacking a duplicate
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, 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 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; 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.
The single-use short-TTL token is not paranoia: **a profile URL that stays valid is a password that
stays valid**, sitting on a public HTTPS endpoint with no authentication.
### 3.5 Signing
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 $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.
**On using the Let's Encrypt TLS cert to sign:** iOS checks only that the signing certificate chains
to a trusted root and has not expired. It does **not** check EKU/key usage, and the certificate CN does
not have to match the host serving the profile. Multiple practitioner sources report LE-signed profiles
showing as verified; Apple publishes no requirements document for profile-signing certificates at all,
so this is well-corroborated practice rather than documented policy. Two real risks:
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. 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 once there is a certificate on the box to sign with.
### 3.6 The install flow — and its hard limits
**Since iOS 12.2 a profile cannot self-install from a web page.** Apple: _"When you download a
configuration profile from a website or an email message in iOS 12.2 or later […] you need to go to
the Settings app to install it."_ There is no way around this without MDM enrollment, which is not
appropriate here.
**Third-party browsers and in-app web views do not work.** `Linking.openURL()` → Safari. Do **not** use
`WKWebView` or an in-app browser; the profile will download and nothing will happen.
Sequence to walk the user through, with screenshots in your UI:
1. Your button → `Linking.openURL(profileUrl)` → Safari opens.
2. Safari alert asking permission to download a configuration profile → **Allow**.
_(Exact button labels are not in any Apple document — Safari's strings are closed. Screenshot from a
real device rather than quoting me.)_
3. **"Profile Downloaded"** sheet → Close.
4. User opens **Settings**. At the top of the root list: **Profile Downloaded** → tap.
5. **Install** (top right) → device passcode → **Install****Install****Done**.
Hard constraints, all Apple-documented:
- **8-minute window.** "If a profile is not installed within 8 minutes of downloading it, it is
automatically deleted." Your instructions screen must not be a wall of text the user reads slowly.
- **One at a time.** Only one downloaded-but-uninstalled profile can exist; a second download replaces
the first.
- **Lockdown Mode blocks profile installation entirely.** The user must disable it, install, re-enable.
- **Stolen Device Protection (iOS 17.3+)** requires disabling — or being in a familiar location —
before installing a configuration profile.
Detect success by probing whether the account exists (see §6) rather than by anything Safari returns —
you get no callback.
Later profiles are viewable and removable at **Settings → General → VPN & Device Management**.
---
## 4. Android — DAVx⁵ intent
### 4.1 There is no `.mobileconfig` equivalent, and there structurally cannot be
AOSP ships **no system DAV client at all** — only the Calendar and Contacts _providers_ plus the
sync-adapter framework. There is no built-in account type for a configuration payload to target.
You also cannot create a DAVx⁵ account programmatically. `AccountManager.addAccountExplicitly` requires
a signature match with the authenticator that owns the account type, enforced server-side in
`AccountManagerService`. You can create _your own_ account type; you can never inject someone else's.
`Settings.ACTION_ADD_ACCOUNT` only opens a filtered picker — no URL, no credentials.
Android Enterprise managed configuration would work, but: DAVx⁵'s free/OSE build has **zero**
managed-config support (no `app_restrictions.xml`, no `RestrictionsManager` anywhere in the tree), the
managed build is a separate paid app (`com.davdroid.managed`, licence-gated, ~€2/user/month with a
5-user minimum), and Google's Android Management API explicitly prohibits _"solutions developed and
used exclusively for first party in-house applications"_ — which is precisely this. Not a path.
**So: DAVx⁵'s documented intent API is the answer.** It is genuinely good.
### 4.2 The intent
Documented at https://manual.davx5.com/integration.html. Identifiers below were confirmed against
`bitfireAT/davx5-ose@main` source, not just the manual.
- **applicationId:** `at.bitfire.davdroid` (same for F-Droid/OSE and Play builds). The Kotlin namespace
is `com.davx5.ose` — that is **not** a package name, don't use it.
- **Activity:** `at.bitfire.davdroid.ui.setup.LoginActivity`, `exported="true"`.
- **Extras** (all optional Strings): `"url"`, `"username"`, `"password"`.
- **URI schemes** on its VIEW/BROWSABLE filter: `caldav`, `caldavs`, `carddav`, `carddavs`, `davx5`.
`caldavs`/`carddavs`/`davx5` are rewritten to `https`; `caldav`/`carddav` to `http`.
- **Account types:** `bitfire.at.davdroid` and `at.bitfire.davdroid.address_book`.
**Gotcha the official manual gets wrong.** `StandardLoginTypesProvider.intentToInitialLoginType()`
decides whether to skip the login-type chooser by inspecting **`intent.data` only** — it never reads the
`url` extra. An extras-only intent (exactly the manual's own snippet) logs _"Did not understand login
intent"_ and shows the type-picker first. **Set both `data` and the extras.** Extras win over any
userinfo in the URI.
Also: the manual documents a `davPath` extra. **No such constant exists in current `main`.** Ignore it.
```kotlin
Intent().apply {
setClassName("at.bitfire.davdroid", "at.bitfire.davdroid.ui.setup.LoginActivity")
data = Uri.parse("https://officer.example.com/") // drives skipLoginTypePage
putExtra("username", email)
putExtra("password", appPassword)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
```
The bare root URL is enough — our `.well-known` redirects handle discovery, and DAVx⁵'s own Radicale
documentation recommends `/` as the base URL for Radicale v3, which is what Officer runs.
### 4.3 React Native specifics
`Linking.sendIntent(action, extras)` **cannot set a component/class name**, and
`react-native-send-intent`'s `openApp()` uses `getLaunchIntentForPackage`, which lands on
`AccountsActivity`, not `LoginActivity`. So:
- **Zero native code:** `Linking.openURL('davx5://user@officer.example.com/')` — matches the implicit
filter. Requires a `<queries>` entry for `canOpenURL` on Android 11+:
```xml
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="davx5" />
</intent>
</queries>
```
**But the password ends up in a URI**, which is worse for logging and clipboard exposure.
- **Recommended: a ~20-line native module** that builds the explicit intent above, so the password
travels as an Intent extra. Do this one.
### 4.4 What the user still does
`LoginActivity` always renders its Compose UI. Best case the user taps through: login detail (pre-filled)
→ Next → resource detection → account name → **Create Account**. ~3 taps.
There is **no `setResult`** — the manual says not to rely on the activity result, and that is accurate.
Detect completion with an `OnAccountsUpdateListener` on account type `bitfire.at.davdroid`, or by
polling `AccountManager.getAccountsByType()` on resume.
### 4.5 DAVx⁵ not installed
Check with `PackageManager` / `canOpenURL`. If absent, send the user to Play
(`market://details?id=at.bitfire.davdroid`) or F-Droid, and tell them which one they're getting — the
Play build is paid (~€6 one-off), the F-Droid build is free and identical. **Say this in the UI.**
Handing someone a paywall they didn't expect is a bad first experience of a self-hosted product.
### 4.6 Alternative: build sync into the Officer app itself
Viable, and worth costing before committing to the DAVx⁵ dependency. You would implement an
`AbstractAccountAuthenticator` + `AbstractThreadedSyncAdapter` under your own account type, writing
into `CalendarContract` / `ContactsContract` with `caller_is_syncadapter=true`. Libraries DAVx⁵ itself
uses: `com.github.bitfireAT:dav4jvm` (the standalone Kotlin WebDAV/CalDAV protocol layer — the piece
worth reusing), `org.mnode.ical4j:ical4j`, `com.googlecode.ez-vcard:ez-vcard`. Copy their dependency
pins: ical4j pulls Apache Commons versions that need a newer Java than a typical `minSdk`, so they
force `commons-codec` 1.17.1 and `commons-lang3` 3.15.0 as `strictly`.
**None of this is expressible from JavaScript.** Manifest components and `res/xml` cannot be registered
at runtime, and `onPerformSync` runs with no JS bridge attached. It is an all-native subproject.
Permissions: `READ/WRITE_CALENDAR` and `READ/WRITE_CONTACTS` are dangerous/runtime with **no
sync-adapter exemption**; `READ/WRITE_SYNC_SETTINGS` are normal. `AUTHENTICATE_ACCOUNTS`,
`MANAGE_ACCOUNTS` and `USE_CREDENTIALS` are marked _removed_ in AOSP and are no-ops since API 23 —
several Android training docs still show them; those docs are stale.
Recommendation: ship the DAVx⁵ intent first. It is days, not months. Revisit only if the dependency
becomes a real problem.
---
## 5. Security requirements — read this one
### 5.1 Non-negotiable
1. **Mint a fresh app password per provisioning action.** Never reuse, never cache. The plaintext is
unrecoverable by design; there is nothing to reuse anyway.
2. **Label with the device name.** Revocation is only usable if the labels are distinguishable.
3. **HTTPS only, always.** `CalDAVUseSSL`/`CardDAVUseSSL` true, `https://` scheme in the Android URI.
4. **The iOS profile URL must be single-use and short-lived** (≤5 minutes, deleted on first fetch). It
is an unauthenticated public URL containing a live password.
5. **Do not persist the plaintext password in the app.** Hand it to the platform mechanism and drop it.
Do not put it in `AsyncStorage`, do not log it, do not include it in crash reports.
6. **Do not put it in a URI if you can avoid it** (the Android native-module argument in §4.3).
7. **Provide revocation in the app.** `GET /api/dav/passwords` to list, `POST /:id/revoke` to kill.
### 5.2 A tradeoff to state out loud
Apple documents `CalDAVPassword`/`CardDAVPassword` as _"only use this in encrypted profiles"_. We are
embedding a password in a **signed but unencrypted** profile served over HTTPS. That is a deliberate
deviation from the documented path, mitigated by TLS, the single-use token and the ≤5-minute TTL —
and by the fact that the credential is scoped to `/dav` and grants nothing but calendar and contacts.
Profile encryption requires knowing the target device's certificate in advance, which we don't have
outside MDM. If this tradeoff is unacceptable to whoever reviews it, the fallback is to omit the
password key entirely — iOS then prompts for it at install time, and the user pastes it. That is one
extra step and still far better than typing the URL.
---
## 6. Verifying it actually worked
You get no success callback on either platform. Options, best first:
1. **Server-side.** After provisioning, poll for `lastUsedAt` becoming non-null on the app-password
entry via `GET /api/dav/passwords`. Unambiguous: a device authenticated with it. This is the
cleanest signal and works identically on both platforms. Verified: `verifyDavAppPassword` writes
`lastUsedAt` on every successful auth, throttled to at most once a minute (a syncing phone hits it
constantly). So allow up to ~60s before treating a null as failure.
2. **Android:** `AccountManager.getAccountsByType("bitfire.at.davdroid")`.
3. **iOS:** query `EKEventStore` / `CNContactStore` for a source matching the account description.
Requires calendar/contacts permission, which you may not want to ask for. Weakest option.
4. **Manual, for support:** `pm2 logs officer | grep '\[dav\]'` on the server — the `ua` string names
the exact daemon that connected.
---
## 7. Suggested order of work
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.
4. Both: sync status + revoke.
## 8. Open questions for whoever picks this up
- Should the setup screen let the user choose _which_ collections sync, or sync everything? DAVx⁵ asks
anyway; iOS does not. Asking twice on Android is worse than not asking at all.
- What happens on re-provisioning? A stable iOS `PayloadIdentifier` replaces the profile cleanly, but
the old app password stays live unless you revoke it. Suggest: revoke the previous
same-labelled password when minting a new one, and say so in the UI.
- Multi-device: nothing prevents it, and the labels make it manageable. Worth a list view in the app
showing every device with a live DAV password.
## 9. Sources
Apple: [CalDAV payload](https://developer.apple.com/documentation/devicemanagement/caldav) ·
[CardDAV payload](https://developer.apple.com/documentation/devicemanagement/carddav) ·
[Install a configuration profile (HT209435 / 102400)](https://support.apple.com/en-us/102400) ·
[Lockdown Mode](https://support.apple.com/en-us/105120) ·
[Device management profiles](https://support.apple.com/guide/deployment/depc0aadd3fe/web)
DAVx⁵: [integration manual](https://manual.davx5.com/integration.html) ·
[source](https://github.com/bitfireAT/davx5-ose)
Android: [`setApplicationRestrictions`](https://developer.android.com/reference/android/app/admin/DevicePolicyManager) ·
[AMI permissible usage](https://developers.google.com/android/management/permissible-usage)
Officer: `src/servers/api/dav/sync-router.ts` (the DAV door), `src/servers/api/dav/router.ts`
(app passwords), `src/servers/hono.ts` (CORS gating + well-known), `docs/nextcloud-replacement.md`.
---
_Written 2026-08-04. §1 is verified against a running deployment and a real iPhone. §3 and §4 are
researched specifications, not shipped code — verify against a device before trusting any tap count._