diff --git a/api/ios-profile.ts b/api/ios-profile.ts index 8ded80f..0b846f7 100644 --- a/api/ios-profile.ts +++ b/api/ios-profile.ts @@ -136,11 +136,21 @@ ${kinds.map((k) => account(k, k === 'CalDAV' ? 'Officer Calendar' : 'Officer Con // 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 = process.env.DAV_PROFILE_SIGN_CERT; - const key = process.env.DAV_PROFILE_SIGN_KEY; + const cert = env('DAV_PROFILE_SIGN_CERT'); + const key = env('DAV_PROFILE_SIGN_KEY'); if (!cert || !key) return null; - return { cert, key, chain: process.env.DAV_PROFILE_SIGN_CHAIN }; + return { cert, key, chain: env('DAV_PROFILE_SIGN_CHAIN') }; }; let warnedUnsigned = false; @@ -161,7 +171,30 @@ async function sign(plist: string): Promise { // 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']; + /* + `-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'); @@ -185,7 +218,42 @@ async function sign(plist: string): Promise { return new Uint8Array(out); } -export type StoredProfile = { url: string; expiresAt: string; signed: boolean }; +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 { @@ -200,8 +268,15 @@ export async function stashIosProfile(params: ProfileParams, publicUrl: string): 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: `${publicUrl.replace(/\/$/, '')}/dav/provision/${token}.mobileconfig`, + url: `${base}/dav/provision/${token}.mobileconfig`, + shortUrl: `${base}/dav/p/${code}`, + code, expiresAt: new Date(expiresAt).toISOString(), signed: signed !== null, };