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:
+81
-6
@@ -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
|
// 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
|
// 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.
|
// 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 signingConfig = () => {
|
||||||
const cert = process.env.DAV_PROFILE_SIGN_CERT;
|
const cert = env('DAV_PROFILE_SIGN_CERT');
|
||||||
const key = process.env.DAV_PROFILE_SIGN_KEY;
|
const key = env('DAV_PROFILE_SIGN_KEY');
|
||||||
if (!cert || !key) return null;
|
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;
|
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
|
// 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
|
// 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.
|
// 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);
|
if (config.chain) args.push('-certfile', config.chain);
|
||||||
args.push('-md', 'sha256');
|
args.push('-md', 'sha256');
|
||||||
|
|
||||||
@@ -185,7 +218,42 @@ async function sign(plist: string): Promise<Uint8Array | null> {
|
|||||||
return new Uint8Array(out);
|
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. */
|
/** 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> {
|
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;
|
const expiresAt = Date.now() + TTL_MS;
|
||||||
pending.set(token, { body, expiresAt });
|
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 {
|
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(),
|
expiresAt: new Date(expiresAt).toISOString(),
|
||||||
signed: signed !== null,
|
signed: signed !== null,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user