Fix FileVault key escrow on ADE-enrolled Macs (#45928)

After ADE enrollment with enable_disk_encryption: true, hosts reported
as unencrypted with the disk-encryption policy failing and no recovery
key escrowed until the user logged out/in or restarted.

## Root cause
Fleet's shared macOS disk-encryption probe was:

```
SELECT 1 FROM disk_encryption
WHERE user_uuid IS NOT "" AND filevault_status = 'on' LIMIT 1
```

On the osquery disk_encryption table, filevault_status and user_uuid
are populated from independent sources: filevault_status from
`fdesetup status`, user_uuid from `diskutil apfs listCryptoUsers`
(the UUID of a user with SecureToken authority to unlock the volume).

In the post-ADE window, even with ForceEnableInSetupAssistant=true,
SecureToken propagation can lag — filevault_status='on' but
user_uuid='' for a brief period that resolves on a session event.
When the predicate failed, the query returned 0 rows and three
downstream behaviors broke in lockstep:

  - host_disks.encrypted flipped to false ("unencrypted")
  - the built-in "Full disk encryption enabled (macOS)" policy failed
  - mdm_disk_encryption_key_file_*_darwin returned encrypted=0,
    gating the PRK ingest and leaving the recovery key un-escrowed

The predicate originated in groob's standard query library entry
from 2021 as a strict compliance check ("is the host actually
protected, with a user able to unlock it?"). When the disk-encryption
status feature shipped in Nov 2022 (PR #8526, issue #3906), the
same string was reused verbatim and later extracted into
usesMacOSDiskEncryptionQuery — never revisited for whether the
SecureToken gate made sense outside the compliance-policy context.

**Related issue:** Resolves #45369
This commit is contained in:
Dante Catalfamo
2026-05-28 16:18:12 -04:00
committed by GitHub
parent d498c1e312
commit 2c47cee122
8 changed files with 107 additions and 17 deletions
+1
View File
@@ -0,0 +1 @@
- Fixed issue where ADE-enrolled macOS didn't report filevault until restarted
@@ -730,7 +730,7 @@ apiVersion: v1
kind: policy
spec:
name: Full disk encryption enabled (macOS)
query: SELECT 1 FROM disk_encryption WHERE user_uuid IS NOT "" AND filevault_status = 'on' LIMIT 1;
query: SELECT 1 FROM disk_encryption WHERE filevault_status = 'on' LIMIT 1;
bash: fdesetup status | grep -q "FileVault is On." && echo 1 || echo 0
description: Checks to make sure that full disk encryption (FileVault) is enabled on macOS devices.
resolution: To enable full disk encryption, on the failing device, select System Preferences > Security & Privacy > FileVault > Turn On FileVault.
@@ -102,7 +102,7 @@ SELECT subject AS device_id FROM certificates WHERE issuer LIKE 'net + windows +
- Query:
```sql
SELECT 1 FROM disk_encryption WHERE user_uuid IS NOT "" AND filevault_status = 'on' LIMIT 1
SELECT 1 FROM disk_encryption WHERE filevault_status = 'on' LIMIT 1
```
## disk_encryption_linux
@@ -330,7 +330,7 @@ SELECT 1 FROM osquery_registry WHERE active = true AND registry = 'table' AND na
- Query:
```sql
WITH
de AS (SELECT IFNULL((SELECT 1 FROM disk_encryption WHERE user_uuid IS NOT "" AND filevault_status = 'on' LIMIT 1), 0) as encrypted),
de AS (SELECT IFNULL((SELECT 1 FROM disk_encryption WHERE filevault_status = 'on' LIMIT 1), 0) as encrypted),
fv AS (SELECT base64_encrypted as filevault_key FROM filevault_prk)
SELECT encrypted, filevault_key FROM de LEFT JOIN fv;
```
@@ -347,7 +347,7 @@ SELECT 1 WHERE EXISTS (SELECT 1 FROM osquery_registry WHERE active = true AND re
- Query:
```sql
WITH
de AS (SELECT IFNULL((SELECT 1 FROM disk_encryption WHERE user_uuid IS NOT "" AND filevault_status = 'on' LIMIT 1), 0) as encrypted),
de AS (SELECT IFNULL((SELECT 1 FROM disk_encryption WHERE filevault_status = 'on' LIMIT 1), 0) as encrypted),
fl AS (SELECT line FROM file_lines WHERE path = '/var/db/FileVaultPRK.dat')
SELECT encrypted, hex(line) as hex_line FROM de LEFT JOIN fl;
```
+6 -7
View File
@@ -52,11 +52,10 @@ spec:
platform: darwin
description: Retrieves the disk encryption status of a macOS device.
query: |
SELECT
1
FROM disk_encryption
WHERE user_uuid IS NOT ""
AND filevault_status = 'on' LIMIT 1
SELECT
1
FROM disk_encryption
WHERE filevault_status = 'on' LIMIT 1
bash: fdesetup status | grep -q "FileVault is On" && echo 1 || echo 0
purpose: Informational
tags: built-in
@@ -335,7 +334,7 @@ spec:
description: Retrieves the encrypted FileVault recovery key for managed macOS devices.
query: |
WITH
de AS (SELECT IFNULL((SELECT 1 FROM disk_encryption WHERE user_uuid IS NOT "" AND filevault_status = 'on' LIMIT 1), 0) as encrypted),
de AS (SELECT IFNULL((SELECT 1 FROM disk_encryption WHERE filevault_status = 'on' LIMIT 1), 0) as encrypted),
fv AS (SELECT base64_encrypted as filevault_key FROM filevault_prk)
SELECT encrypted, filevault_key FROM de LEFT JOIN fv
discovery: filevault_prk
@@ -350,7 +349,7 @@ spec:
description: Retrieves the encrypted FileVault recovery key and checks for related file data on managed macOS devices.
query: |
WITH
de AS (SELECT IFNULL((SELECT 1 FROM disk_encryption WHERE user_uuid IS NOT "" AND filevault_status = 'on' LIMIT 1), 0) as encrypted),
de AS (SELECT IFNULL((SELECT 1 FROM disk_encryption WHERE filevault_status = 'on' LIMIT 1), 0) as encrypted),
fl AS (SELECT line FROM file_lines WHERE path = '/var/db/FileVaultPRK.dat')
SELECT encrypted, hex(line) as hex_line FROM de LEFT JOIN fl;
discovery: filevault_prk # TODO: this query's discovery query also checks for file_lines.
+17 -5
View File
@@ -3921,6 +3921,17 @@ func (ds *Datastore) GetMDMIdPAccountByUUID(ctx context.Context, uuid string) (*
}
func subqueryFileVaultVerifying() (string, []interface{}) {
// A host is "verifying" when the FileVault profile is verifying or verified and
// a key row exists with decryptability not yet confirmed (decryptable IS NULL),
// or when the profile is verifying and the key is decryptable. The previous
// version only matched the verified-status branch, missing the equivalent
// verifying-status case that PopulateOSSettingsAndMacOSSettings (server/fleet/
// hosts.go) reports as Verifying. See https://github.com/fleetdm/fleet/issues/45369.
//
// Note: hdek.host_id IS NOT NULL distinguishes "row exists with NULL decryptable"
// from "no key row at all" — the latter is intentionally not matched here because
// raw_decryptable is mapped to -1 for missing rows (see server/datastore/mysql/
// hosts.go), and the Go logic classifies that as Action Required.
sql := `
SELECT
1 FROM host_mdm_apple_profiles hmap
@@ -3929,15 +3940,16 @@ func subqueryFileVaultVerifying() (string, []interface{}) {
AND hmap.profile_identifier = ?
AND hmap.operation_type = ?
AND (
(hmap.status = ? AND hdek.decryptable IS NULL AND hdek.host_id IS NOT NULL)
(hmap.status IN (?, ?) AND hdek.decryptable IS NULL AND hdek.host_id IS NOT NULL)
OR
(hmap.status = ? AND hdek.decryptable = 1)
)`
args := []interface{}{
mobileconfig.FleetFileVaultPayloadIdentifier,
fleet.MDMOperationTypeInstall,
fleet.MDMDeliveryVerified,
fleet.MDMDeliveryVerifying,
mobileconfig.FleetFileVaultPayloadIdentifier, // profile_identifier
fleet.MDMOperationTypeInstall, // operation_type
fleet.MDMDeliveryVerifying, // branch 1: status IN
fleet.MDMDeliveryVerified, // branch 1: status IN
fleet.MDMDeliveryVerifying, // branch 2: status =
}
return sql, args
}
+60
View File
@@ -5030,6 +5030,66 @@ func testSetVerifiedMacOSProfiles(t *testing.T, ds *Datastore) {
checkHostMDMProfileStatuses()
}
// TestMDMAppleFileVaultSummary_NullDecryptableKey is a regression test for the
// SQL aggregate gap surfaced while investigating
// https://github.com/fleetdm/fleet/issues/45369. When a host has a key row in
// host_disk_encryption_keys with decryptable=NULL (the host reported its key but
// the decryption-verifier hasn't run yet) and the FileVault profile is verifying
// or verified, PopulateOSSettingsAndMacOSSettings (server/fleet/hosts.go) classifies
// the host as Verifying. The previous subqueryFileVaultVerifying only matched the
// Verified-status case, so dashboard aggregates miscounted a Verifying-status host
// in this state.
func TestMDMAppleFileVaultSummary_NullDecryptableKey(t *testing.T) {
ds := CreateMySQLDS(t)
ctx := t.Context()
fvProfile, err := ds.NewMDMAppleConfigProfile(ctx, *generateAppleCP(fleetmdm.FleetFileVaultProfileName, mobileconfig.FleetFileVaultPayloadIdentifier, 0), nil)
require.NoError(t, err)
// Two hosts: one with profile in Verifying status, one in Verified. Both have
// a key row with NULL decryptable. Both should be classified as Verifying.
verifyingHost := test.NewHost(t, ds, "fv-verifying.local", "1.1.1.1", "fv-verifying-key", "fv-verifying-uuid", time.Now())
verifiedHost := test.NewHost(t, ds, "fv-verified.local", "1.1.1.2", "fv-verified-key", "fv-verified-uuid", time.Now())
nanoEnrollUserDeviceAndSetHostMDMData(t, ds, verifyingHost)
nanoEnrollUserDeviceAndSetHostMDMData(t, ds, verifiedHost)
upsertHostCPs([]*fleet.Host{verifyingHost}, []*fleet.MDMAppleConfigProfile{fvProfile}, fleet.MDMOperationTypeInstall, &fleet.MDMDeliveryVerifying, ctx, ds, t)
upsertHostCPs([]*fleet.Host{verifiedHost}, []*fleet.MDMAppleConfigProfile{fvProfile}, fleet.MDMOperationTypeInstall, &fleet.MDMDeliveryVerified, ctx, ds, t)
// Key rows exist, decryptability has not been confirmed (nil → NULL).
_, err = ds.SetOrUpdateHostDiskEncryptionKey(ctx, verifyingHost, "key-a", "", nil)
require.NoError(t, err)
_, err = ds.SetOrUpdateHostDiskEncryptionKey(ctx, verifiedHost, "key-b", "", nil)
require.NoError(t, err)
summary, err := ds.GetMDMAppleFileVaultSummary(ctx, nil)
require.NoError(t, err)
require.NotNil(t, summary)
assert.Equal(t, uint(2), summary.Verifying, "both hosts must count as verifying")
assert.Equal(t, uint(0), summary.ActionRequired)
assert.Equal(t, uint(0), summary.Verified)
assert.Equal(t, uint(0), summary.Enforcing)
assert.Equal(t, uint(0), summary.Failed)
gotVerifying, err := ds.ListHosts(ctx,
fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}},
fleet.HostListOptions{OSSettingsDiskEncryptionFilter: fleet.DiskEncryptionVerifying},
)
require.NoError(t, err)
assert.Len(t, gotVerifying, 2)
// Host-details classification agrees for both hosts.
for _, h := range []*fleet.Host{verifyingHost, verifiedHost} {
profs, err := ds.GetHostMDMAppleProfiles(ctx, h.UUID)
require.NoError(t, err)
mdmData := fleet.MDMHostData{}
mdmData.PopulateOSSettingsAndMacOSSettings(profs, mobileconfig.FleetFileVaultPayloadIdentifier)
require.NotNil(t, mdmData.MacOSSettings)
require.NotNil(t, mdmData.MacOSSettings.DiskEncryption)
assert.Equal(t, fleet.DiskEncryptionVerifying, *mdmData.MacOSSettings.DiskEncryption,
"host %s details must report verifying", h.Hostname)
}
}
func TestCopyDefaultMDMAppleBootstrapPackage(t *testing.T) {
ds := CreateMySQLDS(t)
defer ds.Close()
+8 -1
View File
@@ -582,7 +582,14 @@ func ingestKubequeryInfo(ctx context.Context, logger *slog.Logger, host *fleet.H
return nil
}
const usesMacOSDiskEncryptionQuery = `SELECT 1 FROM disk_encryption WHERE user_uuid IS NOT "" AND filevault_status = 'on' LIMIT 1`
// usesMacOSDiskEncryptionQuery probes whether FileVault is enabled on a macOS host.
// It deliberately does not filter on `user_uuid` — that column reports the SecureToken
// holder and can be empty for a short period after ADE setup completes, even when
// FileVault is on and the recovery key has been escrowed. Gating on user_uuid here
// previously caused the host to appear unencrypted (and the recovery key to remain
// un-escrowed) until the user logged out/in to settle SecureToken propagation. See
// https://github.com/fleetdm/fleet/issues/45369.
const usesMacOSDiskEncryptionQuery = `SELECT 1 FROM disk_encryption WHERE filevault_status = 'on' LIMIT 1`
// extraDetailQueries defines extra detail queries that should be run on the host, as
// well as how the results of those queries should be ingested into the hosts related tables
@@ -2131,6 +2131,17 @@ func TestDirectDiskEncryption(t *testing.T) {
ds.SetOrUpdateHostDisksEncryptionFuncInvoked = false
}
// TestUsesMacOSDiskEncryptionQueryDoesNotGateOnSecureToken guards against reintroducing
// the user_uuid predicate that caused https://github.com/fleetdm/fleet/issues/45369.
// In the post-ADE window the disk_encryption.user_uuid column can be empty while
// FileVault is on; filtering on it makes the host appear unencrypted and blocks
// recovery-key escrow until the user logs out/in.
func TestUsesMacOSDiskEncryptionQueryDoesNotGateOnSecureToken(t *testing.T) {
require.NotContains(t, usesMacOSDiskEncryptionQuery, "user_uuid",
"usesMacOSDiskEncryptionQuery must not filter on user_uuid; see issue #45369")
require.Contains(t, usesMacOSDiskEncryptionQuery, "filevault_status = 'on'")
}
func TestDirectIngestDiskEncryptionWindows(t *testing.T) {
ds := new(mock.Store)
var gotEncrypted bool