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:
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
|
||||
type ProfileParams = {
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password: string;
|
||||
/** The DAV principal, e.g. `/dav/1/`. Both payloads point at it and let the client discover the rest. */
|
||||
principalUrl: string;
|
||||
/** What the user sees as the account name in Settings. */
|
||||
accountName: string;
|
||||
};
|
||||
|
||||
// One profile, both payloads. This is not a convenience: iOS keys accounts by server+username, so adding
|
||||
// CardDAV separately with the same pair gets folded into the existing CalDAV account and contacts
|
||||
// silently never appear. A single profile creates both accounts outright and sidesteps it. Detail in
|
||||
// docs/mobile-dav-provisioning.md §1.6, which cost an evening to learn.
|
||||
function buildPlist(params: ProfileParams): string {
|
||||
const { host, port, username, password, principalUrl, accountName } = params;
|
||||
// Stable per host, so re-running setup REPLACES the profile instead of stacking a second account, and
|
||||
// two Officer instances on one phone do not overwrite each other.
|
||||
const identifier = `dev.officer.dav.${host.replace(/[^a-zA-Z0-9]+/g, '-')}`;
|
||||
const e = escapeXml;
|
||||
|
||||
const account = (kind: 'CalDAV' | 'CardDAV', label: string) => ` <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 & 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;
|
||||
}
|
||||
@@ -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'));
|
||||
|
||||
Reference in New Issue
Block a user