Files
caldav/api/ios-profile.ts
T
pastilhasandClaude Opus 5 0faf9183ad sign with -binary, or iOS refuses the profile it just verified
every signed profile was rejected with "Invalid Profile". `openssl smime -verify`
reported success the whole time, which is exactly why it took so long to see:
the signature was valid over the WRONG BYTES.

without `-binary`, openssl treats the input as TEXT — it canonicalises line
endings and adds MIME headers before signing — so what iOS unwraps is no longer
the plist that was written.

this was already found and written down in officer-oas/enroll.ts on 2026-08-20,
four wrong endings into a hunt that was never about the ending. this file was
written without reading that one, and repeated it exactly.

also reads OFFICERDEV_DAV_PROFILE_SIGN_* as well as the bare names, which is the
convention every other consumer follows. on a converted install the signing
certificate could be configured correctly and silently ignored, and the only
symptom is a profile that says "Not Signed" for no visible reason.

verified on a real iPhone (iOS 26.6): the profile installs, and the signed inner
payload starts with <?xml with no MIME headers and no CRLF mangling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-27 09:49:55 +00:00

304 lines
14 KiB
TypeScript

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.
/*
How long a provisioning link lives.
Five minutes until 2026-08-27, which assumed the person minting the link is the person clicking it —
true for a button in the UI, false for every other way this gets used. Handing a URL to someone who then
has to pick up a phone, open Safari and type it took longer than that every single time, and the failure
is indistinguishable from a broken profile: the link 404s and iOS says "Invalid Profile", which sends you
looking at the plist rather than at the clock. That cost most of an evening.
Thirty minutes, still single-use. The link embeds a live password, so this is a real trade — but the
binding constraint is that it is claimed ONCE and then gone, not that the window is narrow.
*/
const TTL_MS = 30 * 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;
/**
* Which accounts this profile creates. Both, unless something is wrong.
*
* ── Why this had to exist ──
*
* The comment below says one profile carrying both payloads sidesteps iOS folding CardDAV into an
* existing CalDAV account with the same server and username. On iOS 26.6 it does not: installing the
* two-payload profile produced ONE account offering Calendars and Reminders, with no Contacts toggle
* anywhere and 460 cards that the phone never once requested. Verified from the server side — the
* principal advertises `addressbook-home-set`, `/1/contacts/` is typed as an addressbook, and nothing
* had ever asked for it.
*
* So CardDAV gets its own profile, with its own PayloadIdentifier, which iOS has nothing to merge into.
*/
kinds?: ('CalDAV' | 'CardDAV')[];
};
// 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, kinds = ['CalDAV', 'CardDAV'] } = 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 suffix = kinds.length === 1 ? `.${kinds[0]!.toLowerCase()}` : '';
const identifier = `dev.officer.dav.${host.replace(/[^a-zA-Z0-9]+/g, '-')}${suffix}`;
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>
<!-- PayloadScope is deliberately absent: Apple documents it for macOS only, and iOS rejected this
profile outright with "Invalid Profile" while it was present. Removed 2026-08-27. -->
<key>PayloadRemovalDisallowed</key><false/>
<key>PayloadContent</key>
<array>
${kinds.map((k) => account(k, k === 'CalDAV' ? 'Officer Calendar' : 'Officer Contacts')).join('\n')}
</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.
/**
* `OFFICERDEV_X ?? X`, which is the convention every other consumer follows.
*
* This read the bare names only. `.env` on a converted install prefixes everything — see
* `servers/env-prefix.ts` — so on those machines the signing certificate could be configured correctly
* and silently ignored, and the only symptom is a profile that says "Not Signed" for no visible reason.
* The unprefixed name stays as the fallback, never the source.
*/
const env = (name: string) => process.env[`OFFICERDEV_${name}`] ?? process.env[name];
const signingConfig = () => {
const cert = env('DAV_PROFILE_SIGN_CERT');
const key = env('DAV_PROFILE_SIGN_KEY');
if (!cert || !key) return null;
return { cert, key, chain: 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.
/*
`-binary` IS NOT OPTIONAL, and its absence is why every signed profile was refused.
Without it `openssl smime -sign` treats the input as TEXT: it canonicalises line endings and adds MIME
headers before signing. The signature then verifies perfectly — `openssl smime -verify` reports
"Verification successful" — while the bytes iOS unwraps are no longer the plist that was written. The
device shows the profile, then refuses it with "Invalid Profile".
Apple's own sample signs with `OpenSSL::PKCS7::BINARY` for the same reason. This was found and written
down in `officer-oas/enroll.ts` on 2026-08-20, four wrong endings into a hunt that was never about the
ending — and then repeated here on 2026-08-27 because this file was written without reading that one.
*/
const args = [
'smime',
'-sign',
'-binary',
'-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;
/** The same profile at a URL a person can TYPE. See `shortCode`. */
shortUrl: string;
code: string;
expiresAt: string;
signed: boolean;
};
/*
A code short enough to type into Safari's address bar.
── Why a long URL is not good enough ──
The 43-character token cannot be typed, so it has to be tapped or pasted — and on a phone neither
works. Officer runs from the Home Screen in a WEBVIEW, and a webview cannot hand a `.mobileconfig` to
Settings: the download has nowhere to go and the screen does nothing. Every link the chat renders also
gets `target="_blank"`, and a popup tab breaks the profile hand-off in Safari too.
So the only path that reliably reaches iOS is a person typing a URL into real Safari, and that means it
has to be short.
── Why this is not a weaker credential ──
Crockford's alphabet minus the ambiguous letters, 8 characters: ~10^12 combinations. It is SINGLE USE
and lives ten minutes, so an attacker gets one guess per attempt against a target that disappears when
it is claimed or when the clock runs out. The long token still exists and still works; this is a second
door to the same one-shot entry, not a replacement for it.
*/
const CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTVWXYZ23456789';
const CODE_TTL_MS = 10 * 60_000;
const shortCode = (): string => {
const bytes = randomBytes(8);
return Array.from(bytes, (b) => CODE_ALPHABET[b % CODE_ALPHABET.length]).join('');
};
/** 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 });
// The same profile behind a typeable code. Shorter-lived on purpose — see `shortCode`.
const code = shortCode();
pending.set(code, { body, expiresAt: Date.now() + CODE_TTL_MS });
const base = publicUrl.replace(/\/$/, '');
return {
url: `${base}/dav/provision/${token}.mobileconfig`,
shortUrl: `${base}/dav/p/${code}`,
code,
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) {
// SAID OUT LOUD, because the alternative is silence at the only moment it matters.
//
// A miss means expired, already used, or never existed, and the caller cannot be told which — telling
// them would confirm a token once existed. But WE need to know: the response is a 404, iOS renders
// that as "Invalid Profile", and the person reading it goes and inspects the plist. Nothing in the log
// said a phone had even asked. One line here is the difference between "the link expired" and an
// evening spent validating XML that was correct the whole time.
console.warn(`[dav] ios profile claim MISSED (expired, already used, or never minted) — ${pending.size} pending`);
return null;
}
pending.delete(token);
console.log(`[dav] ios profile claimed — ${pending.size} still pending`);
return entry.body;
}