APP PASSWORDS: sign the Apple configuration profiles (Verified on iOS/macOS) #372

Closed
opened 2026-09-28 18:43:03 +00:00 by kayg · 11 comments
Owner

Goal

Sign the Apple configuration profiles (.mobileconfig) served by the App Passwords setup (#318), so iOS and macOS show them as Verified instead of "Unsigned".

Proven by Claude (2026-09-28, real macOS 27 VM)

A CMS/PKCS#7 signed-data envelope (openssl smime -sign -nodetach -outform der) made with the owner's Developer ID Application certificate plus the Apple "Developer ID Certification Authority" intermediate (https://www.apple.com/certificateauthority/DeveloperIDCA.cer) makes macOS verify the profile: security cms -D -h 3 -i profile.mobileconfig prints signer0.status=GoodSignature. The certificate and key are private and never enter the repo, issues or logs.

Build

  • Server config: optional profile_signing with certificate_pem_file, private_key_file and chain_pem_file (paths inside the data or secrets directory), loaded at startup through calternal-fs rules. When the config is absent, the server serves unsigned profiles exactly as today. When the files are invalid or expired, startup logs one warning and falls back to unsigned; never a 5xx.
  • Sign every served profile (iPhone QR download and Mac download) as DER CMS signed-data with the content attached (nodetach), SHA-256, including the chain. Use the openssl crate the workspace already vendors (or RustCrypto cms if it is already a dependency); do not shell out.
  • Content type stays application/x-apple-aspen-config. The profile body must stay byte-identical before signing (tests compare).
  • The key never leaves memory, and it is never logged or returned. Key file permissions: refuse to load a key file readable by group or others (log why).
  • Settings → Admin → Server configuration shows "Profile signing: on (Developer ID Application: , expires )" or "off", with the expiry warning at 30 days. Do not show the team ID.
  • Tests: generate a throwaway CA, intermediate and leaf in the test (rcgen or openssl) and verify the signature and chain with openssl in-process; add a test for the unsigned fallback and for an expired certificate. No real Apple material in fixtures.
  • Adversarial round on the profile endpoints (cross-user download, oversized names, Unicode in the display name, a burst of 200 downloads: CPU per signature stays small).
  • Docs: deploy/cloud/README.md explains where the operator puts the files (podman secret or a read-only mount), in ASD-STE100.

Claude installs the real certificate on calternal.cloud after the merge and verifies on the Mac VM and an iPhone profile download.

## Goal Sign the Apple configuration profiles (`.mobileconfig`) served by the App Passwords setup (#318), so iOS and macOS show them as **Verified** instead of "Unsigned". ## Proven by Claude (2026-09-28, real macOS 27 VM) A CMS/PKCS#7 signed-data envelope (`openssl smime -sign -nodetach -outform der`) made with the owner's **Developer ID Application** certificate plus the Apple "Developer ID Certification Authority" intermediate (https://www.apple.com/certificateauthority/DeveloperIDCA.cer) makes macOS verify the profile: `security cms -D -h 3 -i profile.mobileconfig` prints `signer0.status=GoodSignature`. The certificate and key are private and never enter the repo, issues or logs. ## Build - Server config: optional `profile_signing` with `certificate_pem_file`, `private_key_file` and `chain_pem_file` (paths inside the data or secrets directory), loaded at startup through calternal-fs rules. When the config is absent, the server serves unsigned profiles exactly as today. When the files are invalid or expired, startup logs one warning and falls back to unsigned; never a 5xx. - Sign every served profile (iPhone QR download and Mac download) as DER CMS signed-data with the content attached (nodetach), SHA-256, including the chain. Use the `openssl` crate the workspace already vendors (or RustCrypto `cms` if it is already a dependency); do not shell out. - Content type stays `application/x-apple-aspen-config`. The profile body must stay byte-identical before signing (tests compare). - The key never leaves memory, and it is never logged or returned. Key file permissions: refuse to load a key file readable by group or others (log why). - Settings → Admin → Server configuration shows "Profile signing: on (Developer ID Application: <CN>, expires <date>)" or "off", with the expiry warning at 30 days. Do not show the team ID. - Tests: generate a throwaway CA, intermediate and leaf in the test (rcgen or openssl) and verify the signature and chain with openssl in-process; add a test for the unsigned fallback and for an expired certificate. No real Apple material in fixtures. - Adversarial round on the profile endpoints (cross-user download, oversized names, Unicode in the display name, a burst of 200 downloads: CPU per signature stays small). - Docs: deploy/cloud/README.md explains where the operator puts the files (podman secret or a read-only mount), in ASD-STE100. Claude installs the real certificate on calternal.cloud after the merge and verifies on the Mac VM and an iPhone profile download.
Author
Owner

Starting profile signing on branch job/profile-sign, based on dev at fba83527f2.

Starting profile signing on branch job/profile-sign, based on dev at fba83527f2cccf2334934bb1fd0932be7c0e209b.
Author
Owner

Configuration decision for #372: certificate, private key, and chain paths are relative to the held .system/secrets/ directory and must use the secrets/ prefix. The loader reads them through calternal-fs, refuses group/other-readable keys, and treats invalid or unavailable signing material as a warning plus unsigned delivery. The Admin status omits the signing file paths and strips the certificate Team ID from its displayed name.

Configuration decision for #372: certificate, private key, and chain paths are relative to the held `.system/secrets/` directory and must use the `secrets/` prefix. The loader reads them through `calternal-fs`, refuses group/other-readable keys, and treats invalid or unavailable signing material as a warning plus unsigned delivery. The Admin status omits the signing file paths and strips the certificate Team ID from its displayed name.
Author
Owner

Adversarial probe update: the profile round now checks attached CMS signatures as well as unsigned plist fallback. Its opt-in ADVERSARIAL_PROFILE_SIGNING=1 fixture creates a throwaway CA and leaf under the disposable local server directory; the probe also covers Unicode User display names, oversized app-password names, cross-user attempts, concurrent one-use delivery, and 200 requests against a consumed link. Shell, Node and Python syntax checks pass. I will run the profile round against the real local server before final gates.

Adversarial probe update: the profile round now checks attached CMS signatures as well as unsigned plist fallback. Its opt-in `ADVERSARIAL_PROFILE_SIGNING=1` fixture creates a throwaway CA and leaf under the disposable local server directory; the probe also covers Unicode User display names, oversized app-password names, cross-user attempts, concurrent one-use delivery, and 200 requests against a consumed link. Shell, Node and Python syntax checks pass. I will run the profile round against the real local server before final gates.
Author
Owner

Focused tests and API generation are complete. cargo test -p calternal-auth passed 58 tests, including attached CMS verification against the throwaway root/intermediate/leaf chain, exact content recovery, SHA-256, unsigned fallback, expiry rejection and the 30-day warning. cargo test -p calternal-server passed 71 tests (2 existing ignored), including secrets-path confinement and key-mode rejection. The generated OpenAPI contract and TypeScript client now include the safe Admin signing status; they do not return signing paths in that status.

Focused tests and API generation are complete. `cargo test -p calternal-auth` passed 58 tests, including attached CMS verification against the throwaway root/intermediate/leaf chain, exact content recovery, SHA-256, unsigned fallback, expiry rejection and the 30-day warning. `cargo test -p calternal-server` passed 71 tests (2 existing ignored), including secrets-path confinement and key-mode rejection. The generated OpenAPI contract and TypeScript client now include the safe Admin signing status; they do not return signing paths in that status.
Author
Owner

cargo clippy --all-targets -- -D warnings reported two new lints in crates/calternal-auth/src/profile_signing.rs: ProfileSigningStatus::default() is derivable at line 49, and validate_certificate(&parent, ...) has a needless borrow at line 152. I am correcting both before continuing the final gates.

`cargo clippy --all-targets -- -D warnings` reported two new lints in `crates/calternal-auth/src/profile_signing.rs`: `ProfileSigningStatus::default()` is derivable at line 49, and `validate_certificate(&parent, ...)` has a needless borrow at line 152. I am correcting both before continuing the final gates.
Author
Owner

Committed 8d0f9137 to fix the two cargo clippy --all-targets -- -D warnings findings in profile_signing.rs. Focused verification passed: cargo clippy -p calternal-auth --all-targets -- -D warnings ended with Finished \dev` profile [unoptimized + debuginfo] target(s) in 3m 54s; cargo test -p calternal-authreportedtest result: ok. 58 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 202.08s`.

Committed `8d0f9137` to fix the two `cargo clippy --all-targets -- -D warnings` findings in `profile_signing.rs`. Focused verification passed: `cargo clippy -p calternal-auth --all-targets -- -D warnings` ended with `Finished \`dev\` profile [unoptimized + debuginfo] target(s) in 3m 54s`; `cargo test -p calternal-auth` reported `test result: ok. 58 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 202.08s`.
Author
Owner

The full cargo test gate reached an existing Collaboration performance test and stopped on the 2-second timing assertion. Evidence from the test output: parse=968.637865ms, Yrs=1.423472373s, block-index=32.046625ms, first-sync=53.932738ms, snapshot=61.047823ms, total=2.539137424s. The test is session::public_edit_limit_tests::ten_thousand_markdown_blocks_open_and_sync_within_two_seconds; 14 other tests in that crate passed. I did not change its expectation. This is a SLOW-only shared-host result under the owner rule. The auth suite in the same workspace run passed all 58 tests.

The full `cargo test` gate reached an existing Collaboration performance test and stopped on the 2-second timing assertion. Evidence from the test output: parse=968.637865ms, Yrs=1.423472373s, block-index=32.046625ms, first-sync=53.932738ms, snapshot=61.047823ms, total=2.539137424s. The test is `session::public_edit_limit_tests::ten_thousand_markdown_blocks_open_and_sync_within_two_seconds`; 14 other tests in that crate passed. I did not change its expectation. This is a SLOW-only shared-host result under the owner rule. The auth suite in the same workspace run passed all 58 tests.
Author
Owner

bun run --cwd apps/web check reported one TypeScript error in apps/web/src/routes/settings/admin/AdminSection.svelte:81: status.expires_at is number | undefined, but expiryDate requires number. I am correcting the optional-field handling; the check reported 0 warnings.

`bun run --cwd apps/web check` reported one TypeScript error in `apps/web/src/routes/settings/admin/AdminSection.svelte:81`: `status.expires_at` is `number | undefined`, but `expiryDate` requires `number`. I am correcting the optional-field handling; the check reported 0 warnings.
Author
Owner

bun run --cwd apps/web test reported 750 passed and 1 failed across 114 files. src/lib/date-formatting.test.ts failed its shared formatter reuse gate because apps/web/src/routes/settings/admin/AdminSection.svelte calls Intl.DateTimeFormat directly. I am switching it to the existing packages/ui/src/time.ts helper; I will not change the test expectation.

`bun run --cwd apps/web test` reported 750 passed and 1 failed across 114 files. `src/lib/date-formatting.test.ts` failed its shared formatter reuse gate because `apps/web/src/routes/settings/admin/AdminSection.svelte` calls `Intl.DateTimeFormat` directly. I am switching it to the existing `packages/ui/src/time.ts` helper; I will not change the test expectation.
Author
Owner

Finished — profile signing (#372)

Built and pushed job/profile-sign at 88c7bf7fc39c2d79061208bbead293605b06bd99. The branch includes one merge of dev (95520d54); it was not merged into dev.

The server now signs app password configuration profiles as attached DER CMS SignedData, using the exact plist bytes and RSA/SHA-256. It validates the signing certificate chain and expiry. Signer files load through calternal-fs under .system/secrets; invalid or unavailable signing configuration warns and leaves profiles unsigned. Admin configuration shows safe signer status and an expiry warning. OpenAPI/client types, deployment docs, and the profile endpoint adversarial probe were updated.

Files: crates/calternal-auth/src/profile_signing.rs, crates/calternal-auth/src/api.rs, crates/calternal-auth/src/lib.rs, crates/calternal-fs/src/root.rs, crates/calternal-fs/src/lib.rs, crates/calternal-server/src/wire.rs, apps/web/src/routes/settings/admin/AdminSection.svelte, apps/web/src/routes/settings/admin/ConfigGroup.svelte, contracts/openapi.json, packages/api-client/src/generated.ts, deploy/cloud/README.md, and tests/adversarial/{attack2.py,run.sh,setup.mjs}.

Visual evidence

Captured from the production build against a real local server at 390, 820, and 1440 px in light and dark themes. All six screenshots are attached to this issue.

Admin configuration, 390 px, light
Admin configuration, 390 px, dark
Admin configuration, 820 px, light
Admin configuration, 820 px, dark
Admin configuration, 1440 px, light
Admin configuration, 1440 px, dark

Verification output

cargo fmt --check exited 0 with no stdout or stderr.

cargo clippy --all-targets -- -D warnings final output:

    Finished `dev` profile [unoptimized + debuginfo] target(s) in 10m 18s

cargo test reached an existing Collaboration timing assertion. Auth passed all 58 tests. The collaboration test reported:

10,000-block collaboration phases: parse=968.637865ms, Yrs=1.423472373s, block-index=32.046625ms, first-sync=53.932738ms (616204 bytes), snapshot=61.047823ms (616199 bytes), total=2.539137424s
10,000-block open, first sync and snapshot took 2.539137424s
test result: FAILED. 14 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.00s
error: test failed, to rerun, pass `-p calternal-collab --lib`

This is the existing 2-second performance assertion under shared-host load. I kept its expectation unchanged.

bun run --cwd apps/web check:

svelte-check found 0 errors and 0 warnings

bun run --cwd apps/web test:

 Test Files  114 passed (114)
      Tests  751 passed (751)

The final production build output included:

  Wrote site to "build"
  ✔ done

It emits existing dependency use client directive warnings. Build output was removed after verification.

cargo clean:

     Removed 23143 files, 16.6GiB total

The one signed profile adversarial round ended with:

server alive at end: True
==== ROUND 2 FINDINGS 0
==== ROUND 2 SLOW 1
 - app profile signing latency :: 4.2s status 200

Decisions not specified in DESIGN.md

  • Signing is opt-in. A missing or invalid signer warns and uses unsigned profiles.
  • Configured files must use the secrets/ prefix below .system; reads use the directory-relative calternal-fs API. Private keys reject group/other access; deployment docs specify mode 0400 or 0600.
  • The Admin warning threshold is 30 days. Status omits certificate paths and the Team ID.

Known integration note

job/app-pw-scopes (#328) had not landed on dev when I merged it. The api.rs edit here remains at the profile creation call site for the later integration.

## Finished — profile signing (#372) Built and pushed `job/profile-sign` at `88c7bf7fc39c2d79061208bbead293605b06bd99`. The branch includes one merge of `dev` (`95520d54`); it was not merged into `dev`. The server now signs app password configuration profiles as attached DER CMS `SignedData`, using the exact plist bytes and RSA/SHA-256. It validates the signing certificate chain and expiry. Signer files load through `calternal-fs` under `.system/secrets`; invalid or unavailable signing configuration warns and leaves profiles unsigned. Admin configuration shows safe signer status and an expiry warning. OpenAPI/client types, deployment docs, and the profile endpoint adversarial probe were updated. Files: `crates/calternal-auth/src/profile_signing.rs`, `crates/calternal-auth/src/api.rs`, `crates/calternal-auth/src/lib.rs`, `crates/calternal-fs/src/root.rs`, `crates/calternal-fs/src/lib.rs`, `crates/calternal-server/src/wire.rs`, `apps/web/src/routes/settings/admin/AdminSection.svelte`, `apps/web/src/routes/settings/admin/ConfigGroup.svelte`, `contracts/openapi.json`, `packages/api-client/src/generated.ts`, `deploy/cloud/README.md`, and `tests/adversarial/{attack2.py,run.sh,setup.mjs}`. ### Visual evidence Captured from the production build against a real local server at 390, 820, and 1440 px in light and dark themes. All six screenshots are attached to this issue. ![Admin configuration, 390 px, light](https://git.kayg.org/attachments/f69d9b06-7c77-4357-a73d-659a1de1628c) ![Admin configuration, 390 px, dark](https://git.kayg.org/attachments/841051a2-70e4-4ce4-b760-c8ff62d307ef) ![Admin configuration, 820 px, light](https://git.kayg.org/attachments/ea7e57d8-eb09-4ccf-a895-01ebdfff3ae6) ![Admin configuration, 820 px, dark](https://git.kayg.org/attachments/5cf29bb9-5978-4052-9469-ce45d894c125) ![Admin configuration, 1440 px, light](https://git.kayg.org/attachments/3f434e46-4a97-45e4-94ee-8bacfe89fa34) ![Admin configuration, 1440 px, dark](https://git.kayg.org/attachments/33135964-4e53-4c58-a161-defd19f66910) ### Verification output `cargo fmt --check` exited 0 with no stdout or stderr. `cargo clippy --all-targets -- -D warnings` final output: ```text Finished `dev` profile [unoptimized + debuginfo] target(s) in 10m 18s ``` `cargo test` reached an existing Collaboration timing assertion. Auth passed all 58 tests. The collaboration test reported: ```text 10,000-block collaboration phases: parse=968.637865ms, Yrs=1.423472373s, block-index=32.046625ms, first-sync=53.932738ms (616204 bytes), snapshot=61.047823ms (616199 bytes), total=2.539137424s 10,000-block open, first sync and snapshot took 2.539137424s test result: FAILED. 14 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.00s error: test failed, to rerun, pass `-p calternal-collab --lib` ``` This is the existing 2-second performance assertion under shared-host load. I kept its expectation unchanged. `bun run --cwd apps/web check`: ```text svelte-check found 0 errors and 0 warnings ``` `bun run --cwd apps/web test`: ```text Test Files 114 passed (114) Tests 751 passed (751) ``` The final production build output included: ```text Wrote site to "build" ✔ done ``` It emits existing dependency `use client` directive warnings. Build output was removed after verification. `cargo clean`: ```text Removed 23143 files, 16.6GiB total ``` The one signed profile adversarial round ended with: ```text server alive at end: True ==== ROUND 2 FINDINGS 0 ==== ROUND 2 SLOW 1 - app profile signing latency :: 4.2s status 200 ``` ### Decisions not specified in DESIGN.md - Signing is opt-in. A missing or invalid signer warns and uses unsigned profiles. - Configured files must use the `secrets/` prefix below `.system`; reads use the directory-relative `calternal-fs` API. Private keys reject group/other access; deployment docs specify mode 0400 or 0600. - The Admin warning threshold is 30 days. Status omits certificate paths and the Team ID. ### Known integration note `job/app-pw-scopes` (#328) had not landed on `dev` when I merged it. The `api.rs` edit here remains at the profile creation call site for the later integration.
Author
Owner

Merged into dev by Claude after review; deployed to calternal.cloud. Closing.

Merged into dev by Claude after review; deployed to calternal.cloud. Closing.
kayg closed this issue 2026-09-29 00:53:08 +00:00
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
kayg/calternal#372
No description provided.