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

32 KiB

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:

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 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>

PayloadUUIDs 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:

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 → InstallInstallDone.

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.

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+:
    <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 · CardDAV payload · Install a configuration profile (HT209435 / 102400) · Lockdown Mode · Device management profiles

DAVx⁵: integration manual · source

Android: setApplicationRestrictions · AMI 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.