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
This commit is contained in:
2026-08-27 09:49:55 +00:00
co-authored by Claude Opus 5
parent bfb12c2dde
commit 0faf9183ad
+81 -6
View File
@@ -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<Uint8Array | null> {
// 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<Uint8Array | null> {
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<StoredProfile> {
@@ -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,
};