bitlocker key backup resized 1110x600

Introduction

In today’s digital landscape, safeguarding sensitive data is paramount. BitLocker, a built-in encryption feature in Windows, provides a robust layer of protection by encrypting drives. However, ensuring that recovery keys are securely backed up is crucial, especially for workplace-joined devices where users often lack local admin rights. This blog post delves into a solution we’ve implemented to address this challenge, detailing two PowerShell scripts designed to ensure all BitLocker keys, including those on workplace-joined devices, are backed up to Azure Active Directory (Azure AD).

The Challenge

The primary challenge addressed by this solution is the unique scenario posed by workplace-joined devices. These devices typically do not back up BitLocker keys to Azure AD. This is due to how the authentication process for backing up the keys occurs. Normally, a certificate issued to the device is used to authenticate to the API to which the keys are sent. Workplace-joined devices do not have this certificate, so we must back up the keys as the logged-on user. However, admin rights are required to retrieve the keys, making this impractical. Our solution circumvents this issue by first gathering key protector details using an account with local system rights (via Intune deployment), and then performing the backup using user-level certificates. This method ensures the backup of keys even when the user lacks admin rights, thereby enhancing data recovery capabilities.

The Solution

To address this issue, we developed a two-script solution:

  1. Is-Encrypted.ps1: This script identifies BitLocker-protected volumes that are fully encrypted and checks whether their recovery keys have been backed up. It does this by searching both the event logs and a custom log file. If a key is not backed up, the script saves the KeyProtectorId and RecoveryPassword to a text file for further processing.
  2. Backup-RecoveryPasswords.ps1: This script reads the text file generated by the first script and attempts to back up the recovery keys to Azure AD. It authenticates using user-level certificates, a necessary step since only the user has access to these certificates. The script logs all operations, ensuring a comprehensive record of backup attempts and successes.

Deploying the Scripts with Intune Proactive Remediations

To automate the execution of these scripts and ensure consistent backup of BitLocker keys, we deploy them using Intune Proactive Remediations. This method leverages Intune’s ability to execute PowerShell scripts under specific contexts, allowing us to overcome the challenges posed by workplace-joined devices.

Deployment Overview:

  • Is-Encrypted.ps1: This script runs daily under the system context to identify unbacked keys. It is configured as a detection script in a Proactive Remediation that runs every day. (Get the full script below)
  • Backup-RecoveryPasswords.ps1: This script runs every 8 hours under the logged-on user’s context to back up the identified keys using the user’s certificate. It is configured as a detection script in a separate Proactive Remediation. (Get the full script below)

Detailed Steps for Deployment:

  1. Is-Encrypted.ps1 Deployment:
    • Context: System
    • Frequency: Once every day
    • Purpose: Identifies unbacked BitLocker recovery keys and saves them to a text file for further processing.

Steps:

    • In Intune, navigate to Devices > Scripts and Remediations.
    • Select Remediations.
    • Create a new Remediation script.
    • Provide a Name for the remediation. 
    • Select Next.
    • In the detection script file field, upload the Is-Encrypted.ps1 script.
    • Run this script using the logged-on credentials = No.
    • Enforce script signature check = No.
    • Run script in 64-bit PowerShell = Yes.
    • Assign the script to a group and set the schedule to run once per day.
  1. Backup-RecoveryPasswords.ps1 Deployment:
      • Context: Logged-on user
      • Frequency: Every 8 hours
      • Purpose: Backs up the recovery keys identified by the first script to Azure AD using the user’s certificate.

Steps:

    • Create another Remediation script in Intune.
    • Provide a Name for the remediation. 
    • Select Next.
    • In the detection script file field, upload the Backup-RecoveryPasswords.ps1 script.
    • Run this script using the logged-on credentials = Yes.
    • Enforce script signature check = No.
    • Run script in 64-bit PowerShell = Yes.
    • Assign the script to a group and set the schedule to run every 8 hours.

Future Enhancements

Currently, the code for using the BackupToAAD-BitLockerKeyProtector cmdlet for Azure AD joined or hybrid joined devices is commented out in the Is-Encrypted.ps1 script. We plan to conduct further testing and refine this process to ensure seamless integration and support for all device join scenarios. Feel free to uncomment this line and test yourself. 

Conclusion

Data security is non-negotiable, and ensuring that all BitLocker recovery keys are backed up to Azure AD is a critical aspect of this. Our solution, while working around some unsupported scenarios, provides a comprehensive method to secure BitLocker keys for workplace-joined devices. By leveraging both system and user-level access, we ensure no key is left unbacked, safeguarding against data loss.

For those implementing similar solutions, it’s crucial to thoroughly test in your environment and understand the limitations and permissions required. Our approach provides a robust foundation, but customization may be necessary to fit specific organizational needs.

Feel free to reach out for any clarifications or further discussions on BitLocker key management and Azure AD integrations.

Legal Disclaimer

This solution is provided “as is” without any warranty or guarantee of any kind. The author is not liable for any damages or issues that arise from using this solution. It is the user’s responsibility to ensure that the solution is suitable for their environment and does not interfere with other processes or security policies.

Is-Encrypted.ps1

This script should be executed in local system context.

PowerShell
<#
.SYNOPSIS
    Checks for fully encrypted BitLocker volumes with a RecoveryPassword key protector and verifies if the key protectors are backed up.

.DESCRIPTION
    This script scans all BitLocker-protected volumes on the system for fully encrypted volumes with a RecoveryPassword key protector.
    It then checks if the key protectors have been backed up to Azure AD by searching for specific Event IDs in the event logs or
    entries in a custom log file. If not backed up, the script writes the KeyProtectorIds and RecoveryPasswords to a text file
    for further processing.

.AUTHOR
    John Marcum (PJM)
    @MEM_MVP

.LEGAL DISCLAIMER
    This script is provided "as is" without any warranty or guarantee of any kind. The author is not liable for any damages or issues 
    that arise from using this script. It is the user's responsibility to ensure that the script is suitable for their environment 
    and that it does not interfere with other processes or security policies.

.PARAMETER None
    This script does not require any parameters.

.EXAMPLE
    .\Is-Encrypted.ps1
    Runs the script to check for unbacked BitLocker key protectors and logs the results.

.NOTES
    The script must be run with elevated privileges (local system) to access BitLocker key protectors. 
    It is designed to work around the limitation of workplace-joined devices not backing up BitLocker keys to Azure AD.
#>

function Is-Encrypted {
    # Get all BitLocker volumes
    $global:volumes = Get-BitLockerVolume

    # Initialize a counter for fully encrypted volumes with unbacked RecoveryPassword key protector
    $unbackedEncryptedVolumeCount = 0

    # Initialize the file path for storing the KeyProtectorIds and RecoveryPasswords
    $filePath = 'C:\Temp\RecoveryPasswords.txt'
    $customLogFilePath = "C:\Temp\Backup_Log.txt"

    # Check if the file exists, if so, delete it to start fresh
    if (Test-Path $filePath) {
        Remove-Item $filePath -Force
    }

    # Iterate through each volume
    foreach ($volume in $volumes) {
        # Check if the volume is fully encrypted
        if ($volume.VolumeStatus -eq 'FullyEncrypted') {
            # Get key protectors for the volume
            $keyProtectors = $volume.KeyProtector
            $MountPoint = $volume.MountPoint

            # Check if any of the key protectors is of type "RecoveryPassword"
            foreach ($protector in $keyProtectors) {
                if ($protector.KeyProtectorType -eq 'RecoveryPassword') {
                    $keyProtectorId = $protector.KeyProtectorId
                    $isBackedUp = $false

                    # Check if the key protector has a backup event (EventID 845 or 846) or is in custom log file
                    try {
                        # Check for EventID 845
                        $BLBackupEvent845 = Get-WinEvent -ProviderName Microsoft-Windows-BitLocker-API -FilterXPath "*[System[(EventID=845)] and EventData[Data[@Name='ProtectorGUID'] and (Data='$keyProtectorId')]]" -MaxEvents 1 -ErrorAction Stop
                        if ($BLBackupEvent845) {
                            $isBackedUp = $true
                        }

                        # Check for EventID 846 if not already backed up
                        if (-not $isBackedUp) {
                            $BLBackupEvent846 = Get-WinEvent -ProviderName Microsoft-Windows-BitLocker-API -FilterXPath "*[System[(EventID=846)] and EventData[Data[@Name='ProtectorGUID'] and (Data='$keyProtectorId')]]" -MaxEvents 1 -ErrorAction Stop
                            if ($BLBackupEvent846) {
                                $isBackedUp = $true
                            }
                        }
                    }
                    catch {
                        Write-Host "Protector ID $keyProtectorId not found in event logs. Let's check the log file."
                    }

                    # Check the custom log file
                    if (-not $isBackedUp -and (Test-Path $customLogFilePath)) {
                        $customLogContent = Get-Content -Path $customLogFilePath -ErrorAction SilentlyContinue
                        $isBackedUp = $customLogContent -like "*$keyProtectorId*"
                    }

                    # Output the Protector ID and its backup status
                    if ($isBackedUp) {
                        Write-Host "Protector ID: $keyProtectorId backup status is: True"
                    }
                    else {
                        Write-Host "Protector ID: $keyProtectorId backup status is: False"
                    }

                    if (-not $isBackedUp) {
                        $unbackedEncryptedVolumeCount++

                        # Write KeyProtectorId and RecoveryPassword to the file
                        $recoveryPassword = $protector.RecoveryPassword
                        $entry = "KeyProtectorId: $($keyProtectorId), RecoveryPassword: $recoveryPassword"
                        Add-Content -Path $filePath -Value $entry
                    }
                }
            }
        }
    }

    # Output the result based on the count of fully encrypted volumes with unbacked RecoveryPassword key protector
    if ($unbackedEncryptedVolumeCount -ge 1) {
        Write-Host "Encrypted volumes that have not been backed up were found. Details saved to $filePath"   
    }
    else {
        Write-Host "No encrypted volumes that have not been backed up were found. Exiting"
        exit 0
    }
}

# Check for encrypted drives and only save details if not backed up
Is-Encrypted

Backup-RecoveryPasswords.ps1

This script should be executed in the logged-on user context AFTER the Is-Encrypted.ps1 has been executed as local system. Note: Even running PowerShell as admin in the logged-on user context will break this! For manual testing just run the script without admin rights.

PowerShell
<#
.SYNOPSIS
    Backs up BitLocker RecoveryPassword key protectors to Azure AD using user-level certificates.

.DESCRIPTION
    This script reads a file containing unbacked BitLocker RecoveryPassword key protectors generated by another script. 
    It attempts to back up these protectors to Azure AD using the Invoke-WebRequest cmdlet, authenticated with a user certificate.
    This approach addresses the challenge of backing up BitLocker keys for workplace-joined devices where users do not have local admin rights.
    It uses a custom log file to track the backup status to avoid redundant backups.

.AUTHOR
    John Marcum (PJM)
    @MEM_MVP

.LEGAL DISCLAIMER
    This script is provided "as is" without any warranty or guarantee of any kind. The author is not liable for any damages or issues 
    that arise from using this script. It is the user's responsibility to ensure that the script is suitable for their environment 
    and that it does not interfere with other processes or security policies.

.PARAMETER None
    This script does not require any parameters.

.EXAMPLE
    .\Backup-RecoveryPasswords.ps1
    Runs the script to back up BitLocker key protectors to Azure AD.

.NOTES
    The script should be run as the logged-in user who has access to the necessary certificates. 
    It processes the file generated by the preceding script to back up identified key protectors.

#>

# Start Logging
$timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
$LogPath = "C:\Temp\Backup_BitLocker_Keys_$timestamp.log"
Start-Transcript -Path $LogPath

# Set the security protocol to TLS 1.2
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12

# Define the file path where the KeyProtectorIds and RecoveryPasswords are stored and the log for history to prevent redundant backups.
$filePath = 'C:\Temp\RecoveryPasswords.txt'
$customLogFilePath = "C:\Temp\Backup_Log.txt"

# Function to log messages to a custom log file
function Log-Message {
    param (
        [string]$message
    )
    Add-Content -Path $customLogFilePath -Value $message
}

# Check if the file exists
if (Test-Path $filePath) {

    # Initialize an array to hold the KeyProtectorId and RecoveryPassword pairs
    $keyProtectorArray = @()
    # Read each line from the file
    $fileContent = Get-Content -Path $filePath

    # Iterate through each line
    foreach ($line in $fileContent) {
        # Use a regular expression to extract KeyProtectorId and RecoveryPassword
        if ($line -match 'KeyProtectorId: (\S+), RecoveryPassword: (\S+)') {
            # Create a custom object for each pair
            $pair = [PSCustomObject]@{
                KeyProtectorId   = $matches[1]
                RecoveryPassword = $matches[2]
            }

            # Add the object to the array
            $keyProtectorArray += $pair
        }
    }

    # Output the array to verify the contents
    $keyProtectorArray

    if ($keyProtectorArray) {
        Write-Host 'We found keys to backup' 

        # Get the device details from dsregcmd /status
        $DsregCmdStatus = dsregcmd /status

        $DeviceId = $null
        $WorkplaceDeviceId = $null
        $TenantId = $null
        $WorkplaceTenantId = $null

        # Split the output into lines
        $lines = $DsregCmdStatus -split "`r?`n"

        # Loop through each line to find DeviceId, WorkplaceDeviceId, TenantId, and WorkplaceTenantId
        foreach ($line in $lines) {
            if ($line -match '^\s*DeviceId\s*:\s*([a-f0-9-]{36})\s*$') {
                $DeviceId = $matches[1]
                Write-Host "Found DeviceId: $DeviceId"
            }
            if ($line -match '^\s*WorkplaceDeviceId\s*:\s*([a-f0-9-]{36})\s*$') {
                $WorkplaceDeviceId = $matches[1]
                Write-Host "Found WorkplaceDeviceId: $WorkplaceDeviceId"
            }
            if ($line -match '^\s*TenantId\s*:\s*([a-f0-9-]{36})\s*$') {
                $TenantId = $matches[1]
                Write-Host "Found TenantId: $TenantId"
            }
            if ($line -match '^\s*WorkplaceTenantId\s*:\s*([a-f0-9-]{36})\s*$') {
                $WorkplaceTenantId = $matches[1]
                Write-Host "Found WorkplaceTenantId: $WorkplaceTenantId"
            }
        }

        # Ensure we have at least one tenant ID to proceed
        if (-not $TenantId -and -not $WorkplaceTenantId) {
            Write-Output 'No valid Tenant IDs found'
            Stop-Transcript
            Exit 1
        }

        # Get the certs that match the device ID or workplace device ID
        $certs = Get-ChildItem -Path cert:CurrentUser\My -Recurse | Where-Object {
            $_.Subject -match "CN=$DeviceId" -or $_.Subject -match "CN=$WorkplaceDeviceId"
        }

        if ($certs.Count -eq 0) {
            Write-Host "No valid certificates found matching the DeviceId or WorkplaceDeviceId."
            Stop-Transcript
            Exit 1
        }

        # Initialize success flag and error log
        $allSuccessful = $true
        $errorLog = @()

        # Attempt backup based on matching certificate
        foreach ($cert in $certs) {
            # Extract the subject name from the certificate
            $certSubject = $cert.Subject -replace "^CN=", "" # Remove the 'CN=' prefix if present
            Write-Output "Checking certificate with subject: $certSubject"

            # Determine the appropriate tenant and device based on the certificate subject
            if ($certSubject -eq $DeviceId) {
                $currentId = $DeviceId
                $currentTenant = $TenantId
            }
            elseif ($certSubject -eq $WorkplaceDeviceId) {
                $currentId = $WorkplaceDeviceId
                $currentTenant = $WorkplaceTenantId
            }
            else {
                Write-Host "No matching DeviceId or WorkplaceDeviceId for certificate subject: $certSubject"
                continue
            }

            foreach ($protector in $keyProtectorArray) {
                $body = @{
                    key = $protector.RecoveryPassword
                    kid = $protector.KeyProtectorId -replace '[{}]', ''
                    vol = "OSV"
                } | ConvertTo-Json

                # URL to send the post to
                $url = "https://enterpriseregistration.windows.net/manage/$currentTenant/device/$($certSubject)?api-version=1.0"

                try {
                    # Send the recovery information using Invoke-WebRequest
                    Write-Host "Sending data to: $($url)"
                    Write-Host "Using Cert: $($cert)"
                    Write-Host "With body: $($body)"
                    $req = Invoke-WebRequest -Uri $url -Body $body -UseBasicParsing -Method Post -Certificate $cert
                    Write-Host "Request return status: $($req.StatusCode)"

                    if ($req.StatusCode -eq 200) {
                        $successMessage = "Backup successful for protector ID: $($protector.KeyProtectorId)"
                        Write-Output $successMessage
                        Log-Message $successMessage
                    }
                    else {
                        $failureMessage = "Backup failed for protector ID: $($protector.KeyProtectorId). HTTP status code: $($req.StatusCode)"
                        Write-Output $failureMessage
                        $allSuccessful = $false
                        $errorLog += @{
                            URL   = $url
                            Error = "HTTP status code: $($req.StatusCode)"
                        }
                        Log-Message $failureMessage
                    }
                }
                catch {
                    $errorMessage = "Failed to post recovery information for protector ID: $($protector.KeyProtectorId). Error: $_"
                    Write-Output $errorMessage
                    $allSuccessful = $false
                    $errorLog += @{
                        URL   = $url
                        Error = $_.Exception.Message
                    }
                    Log-Message $errorMessage
                }
            }
        }

        # Only delete the file if all requests were successful
        if ($allSuccessful) {
            Write-Host "All backups were successful. Deleting the file."
            Remove-Item $filePath -Force
            Stop-Transcript
            Exit 0
        }
        else {
            Write-Host "Not all backups were successful. Retaining the file for further inspection."
            Write-Host "Failed backups details:"
            foreach ($errorDetail in $errorLog) {
                Write-Host "URL: $($errorDetail.URL)"
                Write-Host "Error: $($errorDetail.Error)"
            }
            Stop-Transcript
            Exit 1
        }       
    }
    else {
        Write-Host "No key protectors found in the file"
        Stop-Transcript
        Exit 0
    }
}
else {
    Write-Host "File not found: $filePath"
    Stop-Transcript
    Exit 0
}