Apple (macOS & iOS) Device Attestaton
- Dominant language
- Rust
- Stars
- 2.8k
- Forks
- 115
- Avg merge
- 1d 4h
- Merged PRs (30d)
- 51
Description
# Device Attestation - Hardware-Anchored Enrollment (Apple Secure Enclave, macOS)
This is an epic with initial process and architecture - based on this we need to add:
- posture checks (Attested, Attested & Verified)
- add MFA using certificate issued during Attestation
- activity logs
## 1. Goals
Same as the TPM spec (https://github.com/DefGuard/defguard/issues/3359): prove the identity key resides in genuine hardware, anchor the device record to a durable identity, and issue an X.509 device certificate from the Defguard CA only after successful attestation.
## 2. Platform reality (differences vs TPM)
| | TPM 2.0 | Apple Secure Enclave |
|---|---|---|
| Readable manufacturer credential | EK cert in NV, verifier validates chain itself | **None.** Apple issues attestations via its own servers |
| Trust store | Multi-vendor bundle + AIA chasing | Single root: **Apple Enterprise Attestation Root CA** (Apple Private PKI) |
| Proof gate | None (works self-hosted) | **MDM required** - attestation is delivered via an MDM-installed ACME payload |
| Durable device anchor | EK (survives OS reinstall) | **Serial number** (attested by Apple in the MDA path) |
| Device curve support | No Curve25519, P-256 identity key | Same: SEP is P-256/P-384 only |
| Full attestation requires | TPM 2.0 present | macOS 14+, **Apple Silicon**, MDM enrollment |
Consequence: on Apple, tier T2/T3 is by construction an MDM-integrated (enterprise) feature. Without MDM the ceiling is T1.
## 3. Trust anchors
- **Apple Enterprise Attestation Root CA** - downloaded from Apple Private PKI (`apple.com/certificateauthority/private/`), pinned and versioned in the release (same signed-bundle discipline as §3.2 of the TPM spec, trivially smaller).
- There is **no unknown-CA review flow** here: exactly one issuer is possible. A chain that does not terminate at the pinned Apple root is a hard reject, not a pending review.
- Bundle updates ride releases; root rotation by Apple is a monitored event (community + MDM vendor channels).
## 4. Enrollment paths
### No external MDM: embedded micro-MDM
Attestation is only obtainable through the MDM channel (the ACME payload is a device-management payload; there is no app-accessible attestation API on macOS). If requiring a third-party MDM is unacceptable, Defguard must **speak the MDM protocol itself** - an embedded, attestation-only micro-MDM:
- **Scope (ruthlessly minimal):** enrollment profile, `InstallProfile` for the ACME payload, `DeviceInformation` for attestation refresh. No app management, no policies, no remote wipe. This scoping is also the privacy story - publish it.
- **Enrollment:** user downloads the enrollment `.mobileconfig` from the standard Defguard enrollment flow and approves it in System Settings (user-approved device enrollment; no ABM/DEP required). Must be *device* enrollment - Account-Driven **User Enrollment omits serial and UDID from attestations** by design, which defeats the purpose.
- **Prerequisites:** APNs MDM push certificate. Issuing these to customers requires Defguard to become an Apple-verified MDM vendor (Developer Enterprise Program) - a business/ops cost, not an engineering one; price it into the go/no-go.
- **Precedents:** MicroMDM / NanoMDM (Go, MIT) - embed or re-implement the minimal subset (plist-over-HTTPS check-ins) in Rust.
- **Coexistence rule:** a device holds one MDM enrollment. Deployment picks per device: external MDM present ⇒ Path A; none ⇒ Path A′. Both converge on the identical ACME ceremony above.
#### Two-key model (keychain reality)
On macOS the ACME/MDA-issued identity is **not accessible to third-party apps** (data-protection keychain, no access-group grant - confirmed by field analysis). Therefore the MDA certificate is *not* the client's working credential. Mirror the TPM design:
| Apple | TPM equivalent | Role |
|---|---|---|
| MDA/ACME cert (SEP key, Apple-attested) | EK + EK cert | Device anchor proof - used at enrollment and renewal only |
| In-app SEP key (CryptoKit), certified by Defguard CA within the attestation-authenticated session | Identity key (AK-certified) | Daily device authentication (mTLS, device factor) |
Binding between the two is **session-level, not cryptographic** (Apple provides no cross-key certify primitive) - an accepted, documented delta vs the TPM path, where `TPM2_Certify` binds the keys hard.
### Path B - SEP-claimed key, no attestation (tier T1)
For fleets without MDM, Intel Macs, and macOS < 14. The client generates a P-256 key with `kSecAttrTokenIDSecureEnclave` (CryptoKit `SecureEnclave.P256`), signs a CSR, and submits it through the standard enrollment flow (token + nonce).
- The server **cannot verify** SEP residency - this is a client claim, recorded as such (`tier=T1`, `residency=claimed`).
- Still defeats the primary threat (config-file theft): the private key never exists as a file.
- Post-enrollment verification (§6) carries the human-trust weight in this path; `manual` mode is strongly recommended.
### Managed Device Attestation via ACME `device-attest-01` (tier T2/T3)
Requirements: macOS 14+, Apple Silicon, device enrolled in MDM (Jamf/Intune/Mosyle/...).
Defguard core exposes an **ACME endpoint implementing `device-attest-01`** (draft-acme-device-attest; production precedent: smallstep). The org's MDM pushes an ACME payload pointing at it:
```
DirectoryURL = https://defguard.example.com/acme/device/directory
ClientIdentifier = (binds to enrollment session)
KeyType = ECSECPrimeRandom, KeySize = 256 (hardware-bound SEP key)
HardwareBound = true
Attest = true
```
```mermaid
sequenceDiagram
participant M as MDM
participant D as Device (macOS)
participant AP as Apple attestation servers
participant S as Defguard core (ACME + verifier + CA)
S->>M: 1. device assigned (serial pre-registered) - push ACME payload
M->>D: 2. install ACME payload (HardwareBound, Attest, ClientIdentifier)
D->>S: 3. ACME new-order (device-attest-01)
S->>D: 4. challenge token (freshness nonce)
D->>D: 5. generate hardware-bound key in Secure Enclave
D->>AP: 6. request attestation (key, nonce, device identity)
AP-->>D: 7. attestation chain (leaf + intermediate, rooted in Apple Enterprise Attestation Root CA)
D->>S: 8. challenge response = attestation object + CSR (SEP key)
S->>S: 9. validate chain to pinned Apple root · freshness · serial vs inventory · attested key == CSR key
S->>D: 10. X.509 device certificate (policy OID chain-trust=apple)
S->>S: 11. persist record (anchor = serial) + evidence
```
The attestation leaf carries Apple-asserted device properties: **serial number, UDID, sepOS version** - "genuine hardware" and "this specific device" arrive in a single proof (unlike TPM, where EK chain and EK allowlist are separate checks).
#### Verification rules (server, step 9)
- Leaf and intermediate chain to the pinned Apple Enterprise Attestation Root CA; anything else ⇒ reject.
- Freshness: attestation nonce matches the ACME challenge token (session-bound, non-replayable).
- `ClientIdentifier` matches an open Defguard enrollment for this device.
- Attested serial matches the pre-registered inventory entry (Path A assumes MDM ⇒ inventory exists). Mismatch ⇒ `pending` admin review.
- Public key in the attestation == public key in the CSR; hardware-bound flag present.
- Evidence persisted: full chain, attested properties, ACME order id, timestamps (same audit discipline as TPM spec §8).
## 5. Certificate issuance
Identical to TPM spec §6 (same CA, same profile), with:
- Policy OIDs: `attested=true|claimed`, tier, `chain-trust=apple` (Path A) - vs `vendor|admin-approved` on TPM.
- Renewal: ACME re-order with fresh attestation (Path A); MDM can also trigger periodic `DeviceInformation` attestation for ongoing checks - recorded, not yet evaluated (posture is out of scope).
- **Important**: The client app can't use the ACME payload identity - On macOS the MDA identity is opaque to third-party apps. The two-key model (§4, Path A′) is therefore the design on all Apple paths: MDA cert = enrollment/renewal proof, in-app SEP key = working credential.
## 6. Post-enrollment verification & remote out-of-band verification
### 6.1 Verification state
As in the TPM spec §5.1: `attested` (machine-decided) and `verified` (admin-decided) are orthogonal; the `verified` flag lives server-side in the device record, never in the certificate; policy `post_enrollment_verification = auto | manual` per deployment/group. Path B enrollments are always `manual`.
### 6.2 Remote verification via SAS
Problem, restated for Apple: enrollment is remote; the admin must confirm the pending record corresponds to the physical Mac in front of the real user - not to an attacker enrolling their own (genuinely Apple, validly attesting) machine with a stolen enrollment token. Apple's attestation proves *genuine hardware with this serial*; it does not prove *whose hands it is in*.
Process (same pattern as TPM spec §5.2 - short authentication string, compared over an out-of-band channel):
1. After the ceremony completes, the **Defguard client app** derives and displays the code. It must be displayed by the app, not by any MDM/profile UI - the user must read it from the thing that holds the working key:
`SAS = wordlist_encode( SHA-256("dg-verify-v1" ‖ SHA-256(serial) ‖ in-app identity pub ‖ server nonce)[0..5] )`
40 bits → 4 words from a fixed wordlist. The admin console shows the same code next to the pending device record; both sides derive it independently from the enrollment evidence.
2. **The SAS binds the in-app identity key** (the working credential from the two-key model, §4), not the MDA/ACME certificate. The MDA cert is already cryptographically bound to the serial by Apple; the in-app key is bound only session-level - the SAS is precisely the human-verifiable patch over that gap.
3. Admin contacts the user over an out-of-band channel the org trusts - video call (recommended for remote), phone, in person. The user reads the code from the client UI; the admin compares and clicks verify/reject.
4. Binding argument, per path:
- **MDM/internal mini mdm**: attacker with a stolen token enrolls their own Mac ⇒ different serial, different identity key ⇒ different pending record with a different SAS. The real user has no matching code to read. Additionally the attested serial already failed/passed inventory matching (§4), so SAS is the second, human factor on top of a cryptographic one.
- **Path B**: serial is self-reported, so inventory matching proves nothing - the SAS comparison is the **only** device-record↔machine binding. This is why Path B mandates `manual`.
5. The code is comparison-only (the admin never types it into anything), so 40 bits suffices; the server nonce makes it session-bound and non-precomputable.
6. On verify, the record flips to `verified`; on reject, the issued certificate is revoked and evidence retained for audit.
Same rejected alternative as TPM §5.2: sending a one-time code *to* the user authenticates a channel, but binds nothing to the enrollment evidence.
## 7. Failure policy (degrade, don't block)
| Condition | Result |
|---|---|
| No MDM | Path B ⇒ max T1, `residency=claimed` |
| Intel Mac or macOS < 14 | Path B ⇒ max T1 |
| Attestation chain invalid / not Apple root | Reject + alert (no review path - single legitimate issuer) |
| Attested serial not in inventory | Pending admin review |
| Nonce/ClientIdentifier mismatch | Reject (replay suspicion) + alert |
| Apple attestation servers unreachable | Retry with backoff; enrollment `pending` until resolved |
## 8. Device record & lifecycle
- Anchor: **serial number** (attested in Path A; flagged self-reported in Path B). Stored plaintext - Path A implies a corporate, MDM-managed fleet; the BYOD privacy argument for hashing (TPM spec §8) applies less here, revisit if Path B dominates.
- Continuity: OS reinstall or migration destroys SEP keys but the serial persists ⇒ re-enrollment converges on the same device record. Logic board replacement ⇒ new serial ⇒ new record (admin merge, same as TPM/EK).
- Revocation: identical to TPM spec (internal DB check).
## 9. Security considerations
- Path A trust is **delegated to Apple**: the verifier checks Apple's assertions rather than deriving facts itself. This is weaker in autonomy but stronger in coverage (serial binding included). Document it honestly.
- Path B claims come from client code; treat `T1/claimed` as "protects against credential file theft", never as "attested" in UI or marketing.
- The MDM is in the TCB for Path A: whoever controls the MDM controls payload targeting. Defguard should verify `ClientIdentifier` ↔ enrollment binding server-side, not trust MDM-side configuration alone.
- Relay caveat analogous to TPM (malware on an enrolled Mac can use the key locally); same residual-risk statement.
- **Open ACME endpoint gatekeeping**: any genuine Apple device can produce a valid attestation - genuineness alone must never grant issuance. Authorization comes from the `ClientIdentifier` ↔ open-enrollment binding (Apple's ACME client does not support External Account Binding). Randomized directory paths help but are obscurity, not control.
- Path A′ puts the micro-MDM in the TCB and adds an ops burden (APNs push cert lifecycle, yearly renewal per org) - document for self-hosters.
- Apple rate-limits fresh device attestations (~1 per 7 days) - renewal cadence and retry logic must respect this; don't design short-lived certs around per-connection re-attestation.
## 10. Reserved extensions
- iOS/iPadOS clients via the same ACME endpoint (iOS 16+).
- `DeviceInformation` attestation evaluation (posture tier).
- Unified issuance: the TPM path (companion spec) may later move onto this same ACME endpoint using the WebAuthn `tpm` attestation format - one issuance protocol for all platforms.
## 11. Rust pseudocode
### Server (Rust) - ACME `device-attest-01` challenge validation
```rust
fn validate_apple_attestation(order: &AcmeOrder, resp: &ChallengeResponse,
inv: &Inventory, roots: &AppleRoots) -> Outcome {
let att = parse_attestation_object(&resp.att_obj)?; // WebAuthn "apple" format
let chain = att.x5c(); // leaf + intermediate
// 1. single legitimate issuer - hard reject on anything else
if !chain.verifies_against(roots.enterprise_attestation_root()) {
return Outcome::Reject(NotAppleRoot);
}
// 2. freshness bound to this ACME order
ensure(att.nonce() == sha256(order.challenge_token()))?;
// 3. binding to a defguard enrollment
let enrollment = find_open_enrollment(order.client_identifier())
.ok_or(Reject(UnknownClientIdentifier))?;
// 4. this specific device - attested serial vs inventory
let props = att.device_properties()?; // serial, udid, sep_os
if !inv.contains(&props.serial) {
return Outcome::PendingAdminReview(props);
}
// 5. key binding - attested SEP key == CSR key, hardware bound
ensure(att.public_key() == resp.csr.public_key() && att.hardware_bound())?;
let record = upsert_device(anchor(&props.serial), &props, Evidence::from(&att));
let cert = ca.issue(&resp.csr, PolicyOids { tier: T2, chain_trust: Apple, attested: true })?;
let verified = match policy.post_enrollment_verification {
Auto => Verified::Yes, // defensible: serial is attested
Manual => Verified::No, // await SAS check
};
Outcome::Issued { cert, record, verified, sas: sas_code(&props.serial, &resp.csr, &order.nonce) }
}
```
### Client (Swift) - fallback, SEP-claimed key
```swift
// No MDM available: generate a Secure Enclave key and enroll as T1/claimed.
let key = try SecureEnclave.P256.Signing.PrivateKey() // non-exportable, SEP-resident
let csr = try buildCSR(publicKey: key.publicKey) { digest in
try key.signature(for: digest) // signed inside the enclave
}
let outcome = try await defguard.enroll(csr: csr, token: enrollmentToken, nonce: nonce)
show(sasCode(serial: deviceSerial(), idPub: key.publicKey.rawRepresentation, nonce: nonce))
// Server records: tier = T1, residency = claimed (not attested)
```
Contributor guide
Assessment
This issue has not been assessed yet.