20 KiB
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 systemTCC.dbis unreadable and unwritable with SIP on, andtccutilcan 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
- PPPC and
com.apple.notificationsettingspayloads require MDM. A manually installed.mobileconfigis silently ignored for these payload types. So the two profiles inscripts/macos-notification-fix/are inert until delivered by an enrolled MDM. - Enrollment itself is a GUI flow, not a CLI or "one click." Since macOS 11,
profiles installcannot 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 existingMacVMSurfaceHostinput path (the same surface that already types the bootstrap launch into Terminal). Verify withprofiles status -type enrollment(must report User Approved). - TLS trust bootstrap is a chicken-and-egg that must be solved explicitly.
mdmclientrequires 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) runssecurity add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain nucleic-mdm-ca.cerbefore 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/Keychainsis not SIP-protected, so this works on the SIP-on base. - APNs is the one hard Apple gate — and we can mostly sidestep it. Normally a
server wakes
mdmclientto poll by pushing through Apple's APNs, which needs an Apple MDM push certificate (vendor-gated). But:- At enrollment,
mdmclientperforms an immediate command poll afterTokenUpdate. 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
mdmclientto 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:
mdmclientfetches a device push token for the payload's Topic as part ofTokenUpdate, even though our server never pushes. vmnet NAT provides this. The Topic is just acom.apple.mgmt.External.<uuid>string; the device does not validate that anyone owns it — only sending pushes requires the cert we don't have.
- At enrollment,
- 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 stagesprovision-macos-guest.sh+ assets under/Volumes/My Shared Files/nucleic-provisionand 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 streamingexecop (MacVMEngine.run(name:…), passwordless sudo) to trigger anmdmclientcheck-in — no new vsock op, so the signed guest agent needs no rebuild.MacVMEngine.nudgeMDMCheckIntries 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 pinsnucleic-mdm.host → <gateway>in/etc/hosts(root-writable with SIP on) before enrolling. MacVMBaseStatusflags (provisioned,sipDisabled,agentInstalled,axAgentReady—MacVMSpec.swift): → addmdmEnrolled,profilesInstalled, with matching tokens on the provisioning STATUS line parsed byparseProvisionStatus.
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 identityPKCS12blob + its randomPassword) + an MDM payload (com.apple.mdm:ServerURL,CheckInURL,Topic=com.apple.mgmt.External.<uuid>,IdentityCertificateUUID→ the PKCS#12 payload,AccessRights= 8191,SignMessage= true,CheckOutWhenRemoved= true). - PKCS#12 identity (
MDMDeviceIdentity+PKCS12): the CA issues aclientAuthleaf 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 withopenssl pkcs12 -info/security import. All crypto is cross-platform (_CryptoExtrasPBKDF2/AES-CBC + swift-crypto HMAC), ASN.1 via swift-asn1. - Device auth:
SignMessage=truemakes every check-in/command request carry anMdm-Signatureheader (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 withNWListener). - Command queue semantics: after
TokenUpdate, the device PUTs anIdlestatus to/server; the server replies with the next queued command plist; the device repliesAcknowledged/Errorand re-polls; an empty 200 ends the loop. Queue theInstallProfilecommands 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
InstallProfilefor 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, letmdmclientdrain 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_checkinnudge; the agent (passwordless sudo) triggersmdmclientto check in and drain it. The exact nudge command is a spike (§8.2) — the guest op tries a candidate list (mdmclientcheck-in verbs,launchctl kickstart -kon 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 forxyz.blakeslee.nucleic.vmagent(TeamL7UDTQ6F5W, designated requirement embedded) → removes Data Access Blocked, and keeps SIP on.nucleic-suppress-notifications.mobileconfig— disables notifications forcom.apple.tipsandcom.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: startsNucleicMDMServerwith 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/
MacVMBaseStatusextended, base sealed. Ships the notification fix. - P2 — Mode B runtime. vsock
mdm_checkinop; 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)
- CA trust bootstrap on macOS 27 — does
security add-trusted-cert -d … -k /Library/Keychains/System.keychainstill work headless as root, or does it raise a SecurityAgent prompt (answerable by the computer-use surface)? - No-APNs enrollment drain on macOS 27 — reliable, or timing-sensitive?
(Also: does
TokenUpdatecomplete if APNs is unreachable?) - vsock-triggered
mdmclientcheck-in — which trigger from the candidate list is stable enough to depend on? - 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.
- SCEP interop — mdmclient's SCEP client against our CMS implementation (CACaps honored? AES chosen? nonce/transactionID echo correct?).
- 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-Signaturechained 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.