# Nucleic MDM — a host-side device-management plane for the macOS guests **Status:** in build. Decisions locked (2026-07-12): full scope (server + Mode A + Mode B plumbing), pure-Swift plane in a new `NucleicMDM` SwiftPM target (so it builds/tests in isolation; NucleicCore depends on it), and **one shared logical device identity** across clones. First use case is removing the boot-time notification banners (see `scripts/macos-notification-fix/`), but the goal is general declarative management of the macOS golden base and its clones. **Identity mechanism — revised 2026-07-12 (was: SCEP).** Implementation surfaced that swift-certificates ships no CMS *EnvelopedData* and no customizable *SignedData* (only `@_spi(CMS)` sign/verify), and there is no PKCS#12 serializer in the graph — so a spec-correct SCEP `PKIOperation` would be ~2000 lines of hand-rolled ASN.1 that can only be **round-trip-tested against my own encoder** here (real interop needs a live `mdmclient`, i.e. a hardware spike). We instead ship a **pre-baked PKCS#12 device identity** embedded in the enrollment profile (`com.apple.security.pkcs12`): the host generates the device keypair, the CA issues its cert, and the profile carries the `PKCS12`+`Password`. This is also hand-rolled ASN.1 (RFC 7292: PBES2/PBKDF2-SHA256 + AES-256-CBC shrouded key, and the PKCS#12 v1 MAC), but it is **self-contained and verifiable on-host** with `openssl pkcs12` / `security import`, needs **no `/scep` endpoint**, and makes Mode A + the notification fix work end to end. SCEP moves to the P0 hardware spike (§8): if a future use case needs in-guest key generation, add a `com.apple.security.scep` payload + `/scep` endpoint behind the same `MDMDeviceIdentity` seam. Trade-off: the device private key travels inside the staged profile (host→guest over virtiofs only, never off-host), which is acceptable for self-owned guests. ## 1. Why MDM (and why now) The notification investigation surfaced a wall: on the sealed base **SIP is on and there is no MDM**, and that combination makes three whole classes of guest configuration unreachable from any shell — even root: - **PPPC / TCC grants** (e.g. Full Disk Access for `NucleicVMAgent`) — the system `TCC.db` is unreadable and unwritable with SIP on, and `tccutil` can only reset. - **Per-app notification policy** — no supported CLI; the store is TCC-protected. - Many other privacy, security, and restriction settings. There are exactly three ways to set those on macOS: (a) **SIP off** + direct `sqlite3`/file writes (a per-base recoveryOS `csrutil disable` step; already scaffolded in `provision-macos-guest.sh` Phase 7c/d), (b) **manual GUI** toggles baked into the base, or (c) **MDM**. Only MDM is *declarative, SIP-compatible, and scriptable at scale*. Crucially: > **MDM-delivered PPPC is honored with SIP enabled.** This is the property that > makes MDM the right long-term plane: we keep SIP on (the base's current, safer > posture) and still grant the agent FDA, disable Tips notifications, etc. — all > without touching the notification framework a tester's app relies on. **Honest scope limit:** MDM does **not** retire the SIP-off lane. Screen Recording (`kTCCServiceScreenCapture`) can **never** be silently granted by any PPPC payload — Apple deliberately requires a user click (MDM can only allow a standard user to flip the toggle). The in-guest agent's ScreenCaptureKit path therefore still needs either the SIP-off `TCC.db` write (Phase 7c/d) or a GUI-driven toggle at base-build time. MDM *complements* those lanes (FDA, notifications, Accessibility pre-approval, restrictions); it does not replace them for Screen Recording. ## 2. Apple realities to design around 1. **PPPC and `com.apple.notificationsettings` payloads require MDM.** A manually installed `.mobileconfig` is silently ignored for these payload types. So the two profiles in `scripts/macos-notification-fix/` are inert until delivered by an enrolled MDM. 2. **Enrollment itself is a GUI flow, not a CLI or "one click."** Since macOS 11, `profiles install` cannot install an MDM enrollment profile. The real flow the computer-use surface must drive: stage/open the `.mobileconfig` (queues it as a downloaded profile) → System Settings ▸ General ▸ Device Management ▸ double-click the pending profile → **Install…** → admin password → the profile installs and the enrollment becomes **User-Approved MDM (UAMDM)**. UAMDM is sufficient for PPPC + notification payloads — full ADE/supervision is *not* required. This is several deterministic HID steps during the base build, all driveable by the existing `MacVMSurfaceHost` input path (the same surface that already types the bootstrap launch into Terminal). Verify with `profiles status -type enrollment` (must report *User Approved*). 3. **TLS trust bootstrap is a chicken-and-egg that must be solved explicitly.** `mdmclient` requires the ServerURL/CheckInURL/SCEP URL to be HTTPS with a chain the *system* trusts, and the enrollment handshake happens **during** the profile install — before MDM can deliver anything. Root-cert payloads inside a *manually installed* profile are installed but **not auto-trusted** on modern macOS, so the CA cannot bootstrap itself through the enrollment profile. Resolution: the in-guest enroll step (root, passwordless sudo) runs `security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain nucleic-mdm-ca.cer` **before** opening the enrollment profile. This has historically worked headless as root; if macOS 27 gates it behind a SecurityAgent confirmation, the computer-use surface answers it (spike §8.0). `/Library/Keychains` is not SIP-protected, so this works on the SIP-on base. 4. **APNs is the one hard Apple gate — and we can mostly sidestep it.** Normally a server wakes `mdmclient` to poll by pushing through Apple's APNs, which needs an Apple **MDM push certificate** (vendor-gated). But: - **At enrollment**, `mdmclient` performs an immediate command poll after `TokenUpdate`. A server that has the profile-install commands *queued before enrollment finishes* delivers them in that initial burst **with no APNs at all**. This covers the entire build-time use case. - **Post-enrollment (live clone) commands** need a trigger. Instead of APNs, Nucleic uses its **existing vsock control plane** to tell the in-guest agent to nudge `mdmclient` to check in (see §4). *This is a key spike to validate (§8).* Only if that proves unreliable do we need a real APNs cert. - Note the guest still needs **outbound internet to APNs** (port 5223/443) during enrollment: `mdmclient` fetches a device push token for the payload's Topic as part of `TokenUpdate`, even though our server never pushes. vmnet NAT provides this. The Topic is just a `com.apple.mgmt.External.` string; the device does not validate that anyone owns it — only *sending* pushes requires the cert we don't have. 5. **Cloned identity — decided: one shared logical device.** Every session clone is a block-level copy of one sealed base, so all clones share the base's MDM enrollment identity/cert. The server treats them as one logical device: identical ephemeral machines, identical policy. Mode A needs no runtime MDM contact at all; Mode B nudges are addressed to a specific *running VM* over its own vsock connection, so there is no ambiguity per interaction. Per-clone re-enrollment was rejected: it is **not** cheap — re-enrolling means running the GUI approval flow again (there is no headless path, see #2), which is slow and fragile for ephemeral VMs and buys nothing in a single-host, self-owned fleet. ## 3. How this rides on the existing architecture Nucleic already has every transport MDM needs: - **Host-driven base build over a VirtioFS share** (`MacVMEngine+Provision.swift`, `+Provision27.swift`): the host stages `provision-macos-guest.sh` + assets under `/Volumes/My Shared Files/nucleic-provision` and reads back a STATUS file. → We stage the **CA root, enrollment profile, and enroll script** the same way. - **Host-side computer-use surface** (`MacVMSurfaceHost`, framebuffer + injected HID): → drives the System Settings **UAMDM enrollment flow** during the build, unattended (it already types the bootstrap launch into Terminal today). - **vsock control plane to the in-guest agent** (`MacVMAgentClient`, port 2035; `docs/MACOS_VM_NATIVE_AGENT.md` §3–4): → the **APNs replacement**. The nudge reuses the agent's existing streaming **`exec`** op (`MacVMEngine.run(name:…)`, passwordless sudo) to trigger an `mdmclient` check-in — **no new vsock op**, so the signed guest agent needs no rebuild. `MacVMEngine.nudgeMDMCheckIn` tries the candidate trigger list (§8.2). - **NAT networking** (guest → host gateway reachable): → carries the MDM HTTPS protocol itself, which must be TCP/HTTP. The vmnet subnet is not guaranteed stable, so the enrollment profile does **not** embed a gateway IP: the server's TLS cert carries a stable DNS SAN (`nucleic-mdm.host`), and the in-guest enroll script discovers its default gateway and pins `nucleic-mdm.host → ` in `/etc/hosts` (root-writable with SIP on) before enrolling. - **`MacVMBaseStatus` flags** (`provisioned`, `sipDisabled`, `agentInstalled`, `axAgentReady` — `MacVMSpec.swift`): → add `mdmEnrolled`, `profilesInstalled`, with matching tokens on the provisioning STATUS line parsed by `parseProvisionStatus`. ## 4. Architecture ``` ┌─────────────────────────── HOST (NucleicCore, Swift) ────────────────────────────┐ │ NucleicMDMServer (NucleicMDM target, #if canImport(Network)) │ │ • HTTPS listener (NWListener + TLS; loopback + NAT-facing) speaking: │ │ /checkin — MDM check-in: Authenticate, TokenUpdate, CheckOut │ │ /server — MDM command channel: Idle → command plist → Acknowledged │ │ • MDMCertificateAuthority: local RSA CA (persisted per base); issues the TLS │ │ server identity (SAN nucleic-mdm.host) and the device identity cert │ │ • PKCS#12 device identity baked into the enrollment profile (no /scep endpoint) │ │ • Per-device command queue (InstallProfile / RemoveProfile / …) │ │ • NO APNs: commands drain at enrollment; live nudges come via vsock │ └───────▲───────────────────────────────────────────────────────────▲──────────────┘ │ virtiofs (stage CA cert + enrollment profile + enroll.sh) │ vsock :2035 │ NAT/HTTPS (MDM check-in/command; nucleic-mdm.host→gateway) │ mdmclient nudge ┌───────┴───────────────────────── GUEST (macOS 27) ─────────────────┴──────────────┐ │ enroll step (root): trust CA root → pin /etc/hosts → open enrollment profile │ │ computer-use surface: System Settings ▸ Device Management ▸ Install ▸ password │ │ mdmclient (Apple) ── installs baked PKCS#12 identity; UAMDM after approval ── │ │ NucleicVMAgent ── exec-channel nudge → mdmclient checks in and polls ── │ └───────────────────────────────────────────────────────────────────────────────────┘ ``` ### Protocol specifics (what the server must actually implement) - **Enrollment profile** (generated per base by `MDMEnrollmentProfile`): a PKCS#12 payload (`com.apple.security.pkcs12`: the host-generated device identity `PKCS12` blob + its random `Password`) + an MDM payload (`com.apple.mdm`: `ServerURL`, `CheckInURL`, `Topic` = `com.apple.mgmt.External.`, `IdentityCertificateUUID` → the PKCS#12 payload, `AccessRights` = 8191, `SignMessage` = true, `CheckOutWhenRemoved` = true). - **PKCS#12 identity** (`MDMDeviceIdentity` + `PKCS12`): the CA issues a `clientAuth` leaf for the host-generated device key; `PKCS12` (RFC 7292) wraps key+cert — PBES2 (PBKDF2-HMAC-SHA256 + AES-256-CBC) shrouded key bag, plaintext cert bag, PKCS#12 v1 MAC (HMAC-SHA-256). Verifiable on-host with `openssl pkcs12 -info` / `security import`. All crypto is cross-platform (`_CryptoExtras` PBKDF2/AES-CBC + swift-crypto HMAC), ASN.1 via swift-asn1. - **Device auth:** `SignMessage=true` makes every check-in/command request carry an `Mdm-Signature` header (detached CMS over the body, signed with the baked identity). The server verifies it against the local CA — this is the auth boundary instead of TLS client certs (awkward with `NWListener`). - **Command queue semantics:** after `TokenUpdate`, the device PUTs an `Idle` status to `/server`; the server replies with the next queued command plist; the device replies `Acknowledged`/`Error` and re-polls; an empty 200 ends the loop. Queue the `InstallProfile` commands **before** driving enrollment so the initial burst drains them with no push. ### Two operating modes - **Mode A — build-time, baked (covers the notification fix; ship first).** During the base build: stand up the MDM server, queue `InstallProfile` for the FDA + notifications profiles, run the in-guest enroll step (trust CA, pin hosts, open profile), drive the System Settings approval via the computer-use surface, let `mdmclient` drain the queue at enrollment (**no APNs**), verify (`profiles status -type enrollment` + installed-profile list → STATUS tokens), then seal. Every clone inherits the installed profiles from the snapshot — **no runtime MDM contact required**. - **Mode B — runtime management of live clones (plumbed now, validated later).** For pushing/removing profiles on a *running* session clone: the host starts the MDM server, queues a command, and sends a vsock `mdm_checkin` nudge; the agent (passwordless sudo) triggers `mdmclient` to check in and drain it. The exact nudge command is a spike (§8.2) — the guest op tries a candidate list (`mdmclient` check-in verbs, `launchctl kickstart -k` on the mdmclient daemon, `profiles renew -type enrollment`) and reports which worked. Note the clone's vmnet subnet may differ from build time, so a Mode B session must re-pin `/etc/hosts` (same gateway-discovery logic, via the vsock exec channel). ## 5. Integration points (concrete) | Concern | Where | |---|---| | MDM server + CA + PKCS#12 identity + protocol + queue | new `NucleicMDM` target — **done** | | Spike/ops harness (P0, manual validation) | `Sources/mdm-spike` executable — **done** | | Host-side Mode A / Mode B orchestration | `Sources/NucleicCore/MacVM/MacVMEngine+MDM.swift` (`enrollBaseInMDM`, `pushProfilesToClone`) — **done, spike-grade** | | Guest-side enroll (trust CA, pin hosts, open profile) | staged `mdm-enroll.sh` (generated by `MDMServerController.enrollScript`) — **done** | | Mode B nudge | reuses the agent `exec` op via `MacVMEngine.nudgeMDMCheckIn` — **done** (no new vsock op) | | Base status | `MacVMBaseStatus.mdmEnrolled` / `.profilesInstalled` — **done** | | Provisioning call site + UAMDM GUI coordinates | `MacVMEngine+Provision.swift` phase + `approveEnrollmentViaComputerUse` — **hardware spike (§8.3)** | | First payloads | `scripts/macos-notification-fix/*.mobileconfig` | ## 6. First use case — the notification fix Delivered as Mode A payloads, already authored and `plutil`-clean: - `nucleic-vmagent-fda.mobileconfig` — Full Disk Access for `xyz.blakeslee.nucleic.vmagent` (Team `L7UDTQ6F5W`, designated requirement embedded) → removes **Data Access Blocked**, and keeps SIP on. - `nucleic-suppress-notifications.mobileconfig` — disables notifications for `com.apple.tips` and `com.apple.BTMNotificationAgent` (plus the System Settings/Control Center belt) → removes **Tips** and **Multiple Extensions Added**, leaving all other apps' notifications intact. Plus the non-MDM hygiene already done: the `killall NotificationCenter` sweeper is removed from provisioning (ineffective + it would dismiss a tester's own notifications), and the base must still be **sealed with any pending alert dismissed** so the snapshot doesn't replay it. ## 7. Phased plan - **P0 — spike harness (`mdm-spike`).** The smallest standalone driver of the real server: starts `NucleicMDMServer` with a fresh CA, emits the enrollment profile + CA cert + enroll script to a directory, queues the two profiles, and logs every protocol exchange. Used to validate the load-bearing assumptions (§8) against one throwaway guest — and reusable later as an ops/debug tool. - **P1 — Mode A in provisioning.** Host MDM server + local CA, enrollment staged over virtiofs, UAMDM driven via computer-use, the two profiles installed and verified, STATUS/`MacVMBaseStatus` extended, base sealed. Ships the notification fix. - **P2 — Mode B runtime.** vsock `mdm_checkin` op; host API to push/remove profiles on a live clone (queue + nudge + re-pin hosts). - **P3 — broaden.** Declarative Device Management (DDM) status subscriptions, restriction/config payloads, an operator UI for "base policy," optional ADE supervision, optional real APNs if a use case needs Apple-initiated push. ## 8. Open questions / spikes to validate (on real hardware) 0. **CA trust bootstrap on macOS 27** — does `security add-trusted-cert -d … -k /Library/Keychains/System.keychain` still work headless as root, or does it raise a SecurityAgent prompt (answerable by the computer-use surface)? 1. **No-APNs enrollment drain** on macOS 27 — reliable, or timing-sensitive? (Also: does `TokenUpdate` complete if APNs is unreachable?) 2. **vsock-triggered `mdmclient` check-in** — which trigger from the candidate list is stable enough to depend on? 3. **UAMDM without a human** — the System Settings enrollment flow driven fully by the computer-use surface: exact click/keyboard sequence on macOS 27, and whether the OS gates it further. 4. **SCEP interop** — mdmclient's SCEP client against our CMS implementation (CACaps honored? AES chosen? nonce/transactionID echo correct?). 5. **Trust boundary** — an MDM that can install arbitrary profiles is a powerful primitive: the CA private key and SCEP challenge live with the base bundle (host-only); the server binds loopback + the vmnet-facing address only, and authenticates devices via `Mdm-Signature` chained to our CA. Revisit before any use beyond this host's own guests. ## 9. Alternatives considered - **Lane A (SIP-off build):** cheapest for the notification fix (FDA = one line in Phase 7d), but requires a per-base recoveryOS `csrutil disable`, flips the base to SIP-off, and doesn't generalize to declarative policy. (Still required if the in-guest agent needs Screen Recording — see §1's scope limit.) - **Lane B (GUI baked into base):** zero infra, but manual per-build clicks and no general capability. - **Full APNs MDM:** the "correct" enterprise form, but the Apple push-cert gate and ongoing cert upkeep buy nothing over the vsock-triggered design for guests we already own end-to-end. - **NanoMDM (Go) embedded:** battle-tested protocol handling, but adds a Go binary + cross-process lifecycle + its own storage to a Swift-native host app. Rejected in favor of a pure-Swift server modeled on `MCPApprovalServer`. - **Pre-baked PKCS#12 identity instead of SCEP:** simpler (no CMS/SCEP), but the device private key would travel inside the staged profile; SCEP keeps key generation in-guest and the profile secret-free. Decided: SCEP. The chosen path — **host-side Swift MDM + SCEP + vsock-as-push, SIP stays on** — gets the targeted notification fix *and* a general management plane, without the Apple push-cert dependency, at the cost of the CMS/SCEP implementation and the §8 spikes.