Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
99b0d56
Bitlocker: do not decrypt already encrypted drive.
getvictor Apr 7, 2026
d9daa34
Code review fixes.
getvictor Apr 7, 2026
69b66d0
Code review fixes.
getvictor Apr 7, 2026
62aeec3
CI issues
getvictor Apr 7, 2026
060ee29
Code review fixes.
getvictor Apr 7, 2026
e2d79b4
WIP on self-code review
getvictor Apr 7, 2026
faf2081
Last self-code review?
getvictor Apr 7, 2026
261b740
Merge remote-tracking branch 'origin/main' into victor/40809-bitlocker
getvictor Apr 7, 2026
f35ec5f
Migration bumped.
getvictor Apr 7, 2026
373b1fc
Fixed lint issue
getvictor Apr 7, 2026
1da1f70
Fixed issue and added tests.
getvictor Apr 8, 2026
a762ebe
Merge remote-tracking branch 'origin/main' into victor/40809-bitlocker
getvictor Apr 8, 2026
5b34ad9
Regenerate schema.sql
getvictor Apr 8, 2026
3c559e1
Cache key on escrow failure for fresh encryption path.
getvictor Apr 8, 2026
e9e35b4
Code review fixes.
getvictor Apr 9, 2026
31978c8
Updated comment.
getvictor Apr 9, 2026
bab1468
Updated error message
getvictor Apr 9, 2026
a0484d8
Address Claude comments
getvictor Apr 9, 2026
d745a68
Fix lint
getvictor Apr 9, 2026
582dd7e
Addressed latest Claude comment.
getvictor Apr 9, 2026
363a3ac
Addressed latest Claude comment.
getvictor Apr 9, 2026
b0c6c42
Added test.
getvictor Apr 9, 2026
6739e07
Merge remote-tracking branch 'origin/main' into victor/40809-bitlocker
getvictor Apr 9, 2026
7590bff
Bump migration.
getvictor Apr 9, 2026
30b231b
Removed dead notifs code.
getvictor Apr 9, 2026
500183a
Added test.
getvictor Apr 9, 2026
9eb77c1
Merge remote-tracking branch 'origin/main' into victor/40809-bitlocker
getvictor Apr 9, 2026
3e5e474
Bump migration
getvictor Apr 9, 2026
ff5ece4
Merge remote-tracking branch 'origin/main' into victor/40809-bitlocker
getvictor Apr 9, 2026
dfae539
Regenerate schema
getvictor Apr 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/issue-40809-bitlocker-loop
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Fixed a Windows BitLocker encrypt/decrypt loop on machines with secondary drives using auto-unlock. Fleet now detects disk encryption using `conversion_status` (not just `protection_status`), preventing the server from repeatedly requesting encryption when the disk is already encrypted. Added `bitlocker_protection_status` tracking so the UI shows "Action required" when BitLocker protection is off instead of misleadingly showing "Verified."
Original file line number Diff line number Diff line change
Expand Up @@ -120,14 +120,15 @@ SELECT de.encrypted, m.path FROM disk_encryption de JOIN mounts m ON m.device_al

- Query:
```sql
WITH encrypted(enabled) AS (
SELECT CASE WHEN
WITH bl_available(ok) AS (
SELECT 1 WHERE
NOT EXISTS(SELECT 1 FROM windows_optional_features WHERE name = 'BitLocker')
OR
(SELECT 1 FROM windows_optional_features WHERE name = 'BitLocker' AND state = 1)
THEN (SELECT 1 FROM bitlocker_info WHERE drive_letter = 'C:' AND protection_status = 1)
END)
SELECT 1 FROM encrypted WHERE enabled IS NOT NULL
OR EXISTS(SELECT 1 FROM windows_optional_features WHERE name = 'BitLocker' AND state = 1)
)
SELECT bi.protection_status, bi.conversion_status
FROM bl_available
CROSS JOIN bitlocker_info bi
WHERE bi.drive_letter = 'C:'
```

## disk_space_darwin
Expand Down
1 change: 1 addition & 0 deletions orbit/changes/issue-40809-bitlocker-key-rotation
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- When a Windows disk is already encrypted and Fleet needs the recovery key, orbit now rotates the recovery key (adds a new Fleet-managed protector and removes old ones) instead of decrypting and re-encrypting the entire disk. This avoids the FVE_E_AUTOUNLOCK_ENABLED error loop on machines with secondary drives using auto-unlock.
26 changes: 4 additions & 22 deletions orbit/pkg/bitlocker/bitlocker_management.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,29 +13,11 @@ const (
ConversionStatusDecryptionPaused int32 = 5
)

// Free space wiping status.
//
// Values and their meanings were taken from:
// https://learn.microsoft.com/en-us/windows/win32/secprov/getconversionstatus-win32-encryptablevolume
const (
WipingStatusFreeSpaceNotWiped int32 = 0
WipingStatusFreeSpaceWiped int32 = 1
WipingStatusFreeSpaceWipingInProgress int32 = 2
WipingStatusFreeSpaceWipingPaused int32 = 3
)

// Specifies whether the volume and the encryption key (if any) are secured.
//
// Values and their meanings were taken from:
// https://learn.microsoft.com/en-us/windows/win32/secprov/getprotectionstatus-win32-encryptablevolume
const (
ProtectionStatusUnprotected int32 = 0
ProtectionStatusProtected int32 = 1
ProtectionStatusUnknown int32 = 2
)

const (
// Error Codes
// Error codes from Win32_EncryptableVolume WMI methods. The Microsoft docs
// define these as uint32, but the COM VARIANT transport delivers them as
// VT_I4 (signed 32-bit), which go-ole surfaces as int32. The bit patterns
// are identical (e.g., 0x80310019 as uint32 == -2144272327 as int32).
ErrorCodeInvalidArg int32 = -2147024809 // E_INVALIDARG: encryption flags conflict with Group Policy
ErrorCodeIODevice int32 = -2147023779
ErrorCodeDriveIncompatibleVolume int32 = -2144272206
Expand Down
118 changes: 96 additions & 22 deletions orbit/pkg/bitlocker/bitlocker_management_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,14 @@ const (
EncryptionTypeHardware ForceEncryptionType = 2
)

// fveErrorCode formats a BitLocker error code as "unsigned_decimal (0xHEX)" to
// match the format used in the Microsoft WMI documentation, making errors
// searchable. The WMI docs define return values as uint32 but the COM VARIANT
// transport delivers them as int32 (see comment on the error code constants).
func fveErrorCode(val int32) string {
return fmt.Sprintf("%d (0x%08x)", uint32(val), uint32(val)) // nolint:gosec
}

func encryptErrHandler(val int32) error {
var msg string

Expand All @@ -90,7 +98,7 @@ func encryptErrHandler(val int32) error {
case ErrorCodeProtectorExists:
msg = "key protector cannot be added; only one key protector of this type is allowed for this drive"
default:
msg = fmt.Sprintf("error code returned during encryption: %d", val)
msg = fmt.Sprintf("error code returned during encryption: %s", fveErrorCode(val))
}

return &EncryptionError{msg, val}
Expand Down Expand Up @@ -137,20 +145,6 @@ func (v *Volume) encrypt(method EncryptionMethod, flags EncryptionFlag) error {
return nil
}

// decrypt encrypts the volume
// Example: vol.decrypt()
// https://learn.microsoft.com/en-us/windows/win32/secprov/decrypt-win32-encryptablevolume
func (v *Volume) decrypt() error {
resultRaw, err := oleutil.CallMethod(v.handle, "Decrypt")
if err != nil {
return fmt.Errorf("decrypt(%s): %w", v.letter, err)
} else if val, ok := resultRaw.Value().(int32); val != 0 || !ok {
return fmt.Errorf("decrypt(%s): %w", v.letter, encryptErrHandler(val))
}

return nil
}

// prepareVolume prepares a new Bitlocker Volume. This should be called BEFORE any key protectors are added.
// Example: vol.prepareVolume(bitlocker.VolumeTypeDefault, bitlocker.EncryptionTypeHardware)
// https://docs.microsoft.com/en-us/windows/win32/secprov/preparevolume-win32-encryptablevolume
Expand Down Expand Up @@ -227,6 +221,61 @@ func (v *Volume) deleteKeyProtectors() error {
return nil
}

// deleteKeyProtector removes a single key protector by its ID.
// https://learn.microsoft.com/en-us/windows/win32/secprov/deletekeyprotector-win32-encryptablevolume
func (v *Volume) deleteKeyProtector(protectorID string) error {
resultRaw, err := oleutil.CallMethod(v.handle, "DeleteKeyProtector", protectorID)
if err != nil {
return fmt.Errorf("deleteKeyProtector(%s, %s): %w", v.letter, protectorID, err)
}
if val, ok := resultRaw.Value().(int32); val != 0 || !ok {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here. The spec seems to say this method returns a uint32

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above

return fmt.Errorf("deleteKeyProtector(%s, %s): %w", v.letter, protectorID, encryptErrHandler(val))
}
return nil
}

// Key protector types for GetKeyProtectors.
// https://learn.microsoft.com/en-us/windows/win32/secprov/getkeyprotectors-win32-encryptablevolume
const (
KeyProtectorTypeNumericalPassword int32 = 3
)

// getKeyProtectorIDs returns the IDs of key protectors of the given type.
// https://learn.microsoft.com/en-us/windows/win32/secprov/getkeyprotectors-win32-encryptablevolume
func (v *Volume) getKeyProtectorIDs(protectorType int32) ([]string, error) {
var protectorIDs ole.VARIANT
_ = ole.VariantInit(&protectorIDs)
defer ole.VariantClear(&protectorIDs) //nolint:errcheck

resultRaw, err := oleutil.CallMethod(v.handle, "GetKeyProtectors", protectorType, &protectorIDs)
if err != nil {
return nil, fmt.Errorf("getKeyProtectors(%s, %d): %w", v.letter, protectorType, err)
}
if val, ok := resultRaw.Value().(int32); val != 0 || !ok {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like the docs (https://learn.microsoft.com/en-us/windows/win32/secprov/getkeyprotectors-win32-encryptablevolume) state that KeyProtectorType is a uint32. And this method returns a uint32. Is this an ok conversion?

Copy link
Copy Markdown
Member Author

@getvictor getvictor Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I looked into this. It looks like the library we're using go-ole returns int32 even though the docs say uint32. Given that all other methods in this file do this, I'm not too concerned.

I had Claude do a test case on a Windows machine to verify the resultRaw type.

So since we get int32, we check for errors in that type, like:

ErrorCodeNotDecrypted               int32 = -2144272327

I'll file a bug or fix it in a separate PR since we return these values to the user.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I fixed the error message to not return negative numbers. Now it is:

error code returned during encryption: 2150694953 (0x80310029)

return nil, fmt.Errorf("getKeyProtectors(%s, %d): %w", v.letter, protectorType, encryptErrHandler(val))
}

// The WMI method returns an out-parameter VARIANT containing a SAFEARRAY.
// The array type is VT_ARRAY|VT_VARIANT (0x200C), not VT_ARRAY|VT_BSTR.
// We use ToValueArray() to extract each element as an interface{}, then
// convert to strings. We do NOT call safeArray.Release() here because
// ToArray() wraps the same pointer from the VARIANT without copying --
// defer VariantClear above handles freeing the SAFEARRAY.
safeArray := protectorIDs.ToArray()
if safeArray == nil {
return nil, nil
}

values := safeArray.ToValueArray()
result := make([]string, 0, len(values))
for _, v := range values {
if s, ok := v.(string); ok {
result = append(result, s)
Comment thread
getvictor marked this conversation as resolved.
}
}
return result, nil
}
Comment thread
getvictor marked this conversation as resolved.
Comment thread
getvictor marked this conversation as resolved.

// getBitlockerStatus returns the current status of the volume
// https://learn.microsoft.com/en-us/windows/win32/secprov/getprotectionstatus-win32-encryptablevolume
func (v *Volume) getBitlockerStatus() (*EncryptionStatus, error) {
Expand Down Expand Up @@ -484,20 +533,45 @@ func encryptVolumeOnCOMThread(targetVolume string) (string, error) {
return recoveryKey, nil
}

func decryptVolumeOnCOMThread(targetVolume string) error {
// Connect to the volume
// rotateRecoveryKeyOnCOMThread rotates the recovery key on an already-encrypted volume.
// It adds a new Fleet-managed recovery key protector, removes old recovery key protectors,
// and returns the new recovery key for escrow. The disk is never decrypted.
func rotateRecoveryKeyOnCOMThread(targetVolume string) (string, error) {
vol, err := bitlockerConnect(targetVolume)
if err != nil {
return fmt.Errorf("connecting to the volume: %w", err)
return "", fmt.Errorf("connecting to the volume: %w", err)
}
defer vol.bitlockerClose()

// Start decryption
if err := vol.decrypt(); err != nil {
return fmt.Errorf("starting decryption: %w", err)
// Get existing numerical password (recovery key) protector IDs before adding a new one.
oldProtectorIDs, err := vol.getKeyProtectorIDs(KeyProtectorTypeNumericalPassword)
if err != nil {
return "", fmt.Errorf("listing existing recovery key protectors: %w", err)
}

return nil
// Add a new recovery key protector. Windows generates the recovery password.
newRecoveryKey, err := vol.protectWithNumericalPassword()
if err != nil {
return "", fmt.Errorf("adding new recovery key protector: %w", err)
}

// Remove old recovery key protectors so previously compromised keys are invalidated.
for _, oldID := range oldProtectorIDs {
if err := vol.deleteKeyProtector(oldID); err != nil {
log.Warn().Err(err).Str("protector_id", oldID).Msg("could not delete old recovery key protector, continuing")
}
}

// Ensure a TPM protector exists (some pre-encrypted disks may not have one).
if err := vol.protectWithTPM(nil); err != nil {
// ErrorCodeProtectorExists is expected if a TPM protector is already present.
var encErr *EncryptionError
if !errors.As(err, &encErr) || encErr.Code() != ErrorCodeProtectorExists {
log.Debug().Err(err).Msg("could not add TPM protector, continuing")
}
}

return newRecoveryKey, nil
}

func getEncryptionStatusOnCOMThread() ([]VolumeStatus, error) {
Comment thread
getvictor marked this conversation as resolved.
Expand Down
4 changes: 2 additions & 2 deletions orbit/pkg/bitlocker/bitlocker_worker_notwindows.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,5 @@ func (w *COMWorker) GetEncryptionStatus() ([]VolumeStatus, error) { return nil,
// EncryptVolume is a no-op on non-Windows platforms.
func (w *COMWorker) EncryptVolume(string) (string, error) { return "", nil }

// DecryptVolume is a no-op on non-Windows platforms.
func (w *COMWorker) DecryptVolume(string) error { return nil }
// RotateRecoveryKey is a no-op on non-Windows platforms.
func (w *COMWorker) RotateRecoveryKey(string) (string, error) { return "", nil }
11 changes: 7 additions & 4 deletions orbit/pkg/bitlocker/bitlocker_worker_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,11 @@ func (w *COMWorker) EncryptVolume(targetVolume string) (string, error) {
return key, r.err
}

// DecryptVolume decrypts the specified volume.
func (w *COMWorker) DecryptVolume(targetVolume string) error {
r := w.exec(func() (any, error) { return nil, decryptVolumeOnCOMThread(targetVolume) })
return r.err
// RotateRecoveryKey rotates the recovery key on an already-encrypted volume.
// It adds a new Fleet-managed recovery key, removes old recovery key protectors,
// and returns the new key for escrow. The disk is never decrypted.
func (w *COMWorker) RotateRecoveryKey(targetVolume string) (string, error) {
r := w.exec(func() (any, error) { return rotateRecoveryKeyOnCOMThread(targetVolume) })
key, _ := r.val.(string)
return key, r.err
}
Loading
Loading