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:
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user