diff --git a/README.md b/README.md index 2fc2f86..94f0d86 100644 --- a/README.md +++ b/README.md @@ -246,6 +246,8 @@ E Projects ReFS Fixed Healthy OK - Automatic unlocking is set only when the operating system drive is BitLocker-protected - The volume is then asked whether automatic unlocking really is on, rather than the call being taken at its word - Where automatic unlocking cannot be set up, the encryption is finished all the same: the run gives the reason in the words Windows used, and says the drive will need unlocking by hand after every restart + - **On a machine that denies write access to unencrypted fixed drives, BitLocker is not optional**, and the run says so before the question rather than after the drive exists. The setting is `FDVDenyWriteAccess` under `HKLM\SYSTEM\CurrentControlSet\Policies\Microsoft\FVE` - the path where it takes effect, whatever policy channel delivered it. Skip BitLocker there and the plan says the drive will mount read-only and the run will stop at the write check + - On such a machine Windows may put up its own prompt to encrypt the new drive while the script is already encrypting it. The run warns to leave that prompt alone: answering it only produces "BitLocker encryption already enabled" - What the machine can actually do is checked before the plan is shown, and listed in it - A BitLocker failure offers a choice: retry, carry on without it, or stop - except a refusal by group policy, which is not offered a retry that would meet the same refusal diff --git a/dev_drive.Tests.ps1 b/dev_drive.Tests.ps1 index 4cb9f75..58bf250 100644 --- a/dev_drive.Tests.ps1 +++ b/dev_drive.Tests.ps1 @@ -339,6 +339,39 @@ Describe 'The script itself' { ([regex]::Matches($content, '\$MaxLength = \d')).Count | Should -Be 0 } + It 'reads the write-access setting before it asks about BitLocker, and whatever the answer' { + # The four other machine facts are read only when BitLocker is chosen. This one decides + # whether declining leaves an unusable drive, so it has to be read either way, and first. + $content = Get-Content -Path $script:ScriptPath -Raw + $readAt = $content.IndexOf('$WritePolicy = Get-FixedDriveWritePolicy') + $askAt = $content.IndexOf('$enableBitLocker = Request-BitLockerChoice') + $gateAt = $content.IndexOf('if ($enableBitLocker) {') + $readAt | Should -BeGreaterThan 0 + $askAt | Should -BeGreaterThan $readAt + $gateAt | Should -BeGreaterThan $readAt + # Read unconditionally: a run that declines BitLocker needs this fact most of all. + ([regex]::Matches($content, '(?m)^\$WritePolicy = Get-FixedDriveWritePolicy')).Count | Should -Be 1 + # And handed to the question, or the person answers without knowing it. + $content | Should -Match '-Notes \(Resolve-WriteAccessPolicyAdvice -Policy \$WritePolicy\)' + } + + It 'keeps the policy registry path in one place rather than beside every use of it' { + $content = Get-Content -Path $script:ScriptPath -Raw + ([regex]::Matches($content, '(?m)^\$FixedDriveWritePolicyPath\s*=')).Count | Should -Be 1 + ([regex]::Matches($content, 'CurrentControlSet\\Policies\\Microsoft\\FVE')).Count | Should -Be 1 + ([regex]::Matches($content, '\$PolicyPath = \$script:FixedDriveWritePolicyPath')).Count | Should -Be 2 + } + + It 'states the setting in the plan when BitLocker is being skipped' { + # Nothing exists yet at that point, so the run can still be declined over it. + $content = Get-Content -Path $script:ScriptPath -Raw + $skipAt = $content.IndexOf('* Skip BitLocker encryption') + $adviceAt = $content.IndexOf('Resolve-WriteAccessPolicyAdvice -Policy $WritePolicy', $skipAt) + $confirmAt = $content.IndexOf('Are you ready to proceed') + $adviceAt | Should -BeGreaterThan $skipAt + $confirmAt | Should -BeGreaterThan $adviceAt + } + It 'never tells the user to just try again' { Select-String -Path $script:ScriptPath -Pattern 'try again' | Should -BeNullOrEmpty } @@ -1006,6 +1039,138 @@ Describe 'Get-Win32ErrorText' { } } +Describe 'Request-BitLockerChoice' { + BeforeAll { + Mock Write-Host { } + } + + It 'returns for answer ' -TestCases @( + @{ Answer = '1'; Expected = $true } + @{ Answer = '2'; Expected = $false } + ) { + Mock Read-Host { $Answer } + Request-BitLockerChoice | Should -Be $Expected + } + + It 'keeps asking until the answer is one of the two' { + $script:answers = @('yes', '3', '2') + $script:index = 0 + Mock Read-Host { $script:answers[$script:index++] } + Request-BitLockerChoice | Should -BeFalse + $script:index | Should -Be 3 + } + + It 'prints what it was given about this machine before the menu' { + # A machine where encryption is mandatory has to say so before the answer, not after. + Mock Read-Host { '1' } + Request-BitLockerChoice -Notes @('This machine denies write access to unprotected fixed drives.') | Out-Null + Should -Invoke Write-Host -ParameterFilter { $Object -match 'denies write access' } + } + + It 'prints nothing extra when there is nothing to say' { + Mock Read-Host { '1' } + Request-BitLockerChoice | Out-Null + Should -Not -Invoke Write-Host -ParameterFilter { $Object -match 'denies write access' } + } +} + +Describe 'Get-FixedDriveWritePolicy' { + BeforeAll { + $script:TestKeyRoot = 'HKCU:\Software\DevDriveTests' + $script:FakeKey = "$script:TestKeyRoot\FVE" + } + + AfterAll { + # The whole branch, not just the leaf, so the suite leaves the user's hive as it found it. + if (Test-Path -Path $script:TestKeyRoot) { Remove-Item -Path $script:TestKeyRoot -Recurse -Force } + } + + BeforeEach { + if (Test-Path -Path $script:FakeKey) { Remove-Item -Path $script:FakeKey -Recurse -Force } + } + + It 'reads the setting as on when the value is 1' { + New-Item -Path $script:FakeKey -Force | Out-Null + New-ItemProperty -Path $script:FakeKey -Name 'FDVDenyWriteAccess' -Value 1 -PropertyType DWord -Force | Out-Null + Get-FixedDriveWritePolicy -Path $script:FakeKey | Should -Be 'Deny' + } + + It 'reads the setting as off when the value is 0' { + New-Item -Path $script:FakeKey -Force | Out-Null + New-ItemProperty -Path $script:FakeKey -Name 'FDVDenyWriteAccess' -Value 0 -PropertyType DWord -Force | Out-Null + Get-FixedDriveWritePolicy -Path $script:FakeKey | Should -Be 'Allow' + } + + It 'treats a key without the value as the setting not being set' { + # The ordinary case on an unmanaged machine, and it must not read as "could not be read". + New-Item -Path $script:FakeKey -Force | Out-Null + Get-FixedDriveWritePolicy -Path $script:FakeKey | Should -Be 'Allow' + } + + It 'treats a missing key the same way' { + Get-FixedDriveWritePolicy -Path "$script:TestKeyRoot\NoSuchKey" | Should -Be 'Allow' + } + + It 'answers Unknown for a value of a type it cannot read, rather than reading it as off' { + # A string where a number belongs is an answer this cannot understand, not an absent setting. + New-Item -Path $script:FakeKey -Force | Out-Null + New-ItemProperty -Path $script:FakeKey -Name 'FDVDenyWriteAccess' -Value 'yes' -PropertyType String -Force | Out-Null + Get-FixedDriveWritePolicy -Path $script:FakeKey | Should -Be 'Unknown' + } + + It 'reads a number that is neither 0 nor 1 as the setting not denying writes' { + New-Item -Path $script:FakeKey -Force | Out-Null + New-ItemProperty -Path $script:FakeKey -Name 'FDVDenyWriteAccess' -Value 2 -PropertyType DWord -Force | Out-Null + Get-FixedDriveWritePolicy -Path $script:FakeKey | Should -Be 'Allow' + } + + It 'answers Unknown when the read itself fails' { + Mock Get-ItemProperty { throw 'Requested registry access is not allowed.' } + New-Item -Path $script:FakeKey -Force | Out-Null + Get-FixedDriveWritePolicy -Path $script:FakeKey | Should -Be 'Unknown' + } + + It 'answers Unknown when even the existence check fails, not "not set"' { + # A key that is there but unreadable must never be reported as one that is not there. + Mock Test-Path { throw 'Requested registry access is not allowed.' } + Get-FixedDriveWritePolicy -Path $script:FakeKey | Should -Be 'Unknown' + } +} + +Describe 'Resolve-WriteAccessPolicyAdvice' { + It 'says nothing at all on a machine that allows the writes' { + @(Resolve-WriteAccessPolicyAdvice -Policy 'Allow' -PolicyPath 'HKLM:\X').Count | Should -Be 0 + @(Resolve-WriteAccessPolicyAdvice -Policy 'Allow' -PolicyPath 'HKLM:\X' -Skipping).Count | Should -Be 0 + } + + It 'states the consequence as a fact once the answer to skip is known' { + $lines = (Resolve-WriteAccessPolicyAdvice -Policy 'Deny' -PolicyPath 'HKLM:\X' -Skipping) -join "`n" + $lines | Should -Match 'FDVDenyWriteAccess is 1' + $lines | Should -Match 'this Dev Drive will mount read-only' + $lines | Should -Match 'stop at the write check' + } + + It 'states it as a condition while the answer is still open' { + # Printed before the menu, where nobody has decided to skip anything yet. + $lines = (Resolve-WriteAccessPolicyAdvice -Policy 'Deny' -PolicyPath 'HKLM:\X') -join "`n" + $lines | Should -Match 'would mount read-only' + $lines | Should -Not -Match 'stop at the write check' + $lines | Should -Not -Match 'will mount read-only' + } + + It 'raises an unreadable setting either way, and says where to look' { + foreach ($lines in (Resolve-WriteAccessPolicyAdvice -Policy 'Unknown' -PolicyPath 'HKLM:\Somewhere') -join "`n", + (Resolve-WriteAccessPolicyAdvice -Policy 'Unknown' -PolicyPath 'HKLM:\Somewhere' -Skipping) -join "`n") { + $lines | Should -Match 'could not be read' + $lines | Should -Match "Get-ItemProperty 'HKLM:\\Somewhere' -Name FDVDenyWriteAccess" + } + } + + It 'refuses a policy that is not one of the three' { + { Resolve-WriteAccessPolicyAdvice -Policy 'Maybe' -PolicyPath 'HKLM:\X' } | Should -Throw + } +} + Describe 'Resolve-BitLockerSetupPlan' { It 'asks for a password only in virtual hard disk mode' -TestCases @( @{ Vhdx = $true; Expected = $true } @@ -1014,6 +1179,26 @@ Describe 'Resolve-BitLockerSetupPlan' { (Resolve-BitLockerSetupPlan -VhdxMode:$Vhdx).UsePasswordProtector | Should -Be $Expected } + It 'warns about the read-only spell and Windows'' own prompt where the setting is on' { + # The reporter answered that prompt, got "BitLocker encryption already enabled", and started over. + $notes = (Resolve-BitLockerSetupPlan -WritePolicy 'Deny').Notes -join "`n" + $notes | Should -Match 'read-only' + $notes | Should -Match 'Windows may put up its own prompt' + $notes | Should -Match 'Leave it alone' + } + + It 'says neither where the setting is off' { + $notes = (Resolve-BitLockerSetupPlan).Notes -join "`n" + $notes | Should -Not -Match 'read-only' + $notes | Should -Not -Match 'own prompt' + } + + It 'puts that warning first, before the protector notes' { + # It is about what happens during the run; the rest is about what the drive ends up with. + $notes = @((Resolve-BitLockerSetupPlan -WritePolicy 'Deny').Notes) + $notes[0] | Should -Match 'read-only' + } + It 'adds the domain account protector only on a domain-joined machine' -TestCases @( @{ Joined = $true; Expected = $true } @{ Joined = $false; Expected = $false } @@ -2679,64 +2864,94 @@ Describe 'Request-DedupSchedule' { Describe 'Resolve-WriteProtectionAdvice' { It 'names the drive and says nothing can be written to it' { - (Resolve-WriteProtectionAdvice -MountPoint 'X:' -VolumeState 'Clear') -join "`n" | + (Resolve-WriteProtectionAdvice -MountPoint 'X:' -VolumeState 'Clear' -PolicyPath 'HKLM:\FVE') -join "`n" | Should -Match 'Drive X:.*nothing can be written' } - It 'blames the BitLocker write-access setting only when the drive is unencrypted' { - $lines = (Resolve-WriteProtectionAdvice -MountPoint 'X:' -VolumeState 'Clear') -join "`n" - $lines | Should -Match 'deny write access to fixed drives' - $lines | Should -Match 'PolicyManager' - $lines | Should -Match 'Microsoft.FVE' + It 'names the setting as the likely cause once the run has actually read it' { + # The run reads FDVDenyWriteAccess before the plan now, so this no longer lists suspects. + $lines = (Resolve-WriteProtectionAdvice -MountPoint 'X:' -VolumeState 'Clear' -WritePolicy 'Deny' -PolicyPath 'HKLM:\FVE') -join "`n" + $lines | Should -Match 'almost certainly the cause' + $lines | Should -Not -Match 'may be set to deny' } - It 'keeps that blame conditional, since the setting is never read' { - $lines = (Resolve-WriteProtectionAdvice -MountPoint 'X:' -VolumeState 'Clear') -join "`n" + It 'tells someone how to encrypt the volume that exists, not to create it again' { + # The Dev Drive is already there by this point, so a rerun would repeat the creation. + $lines = (Resolve-WriteProtectionAdvice -MountPoint 'X:' -VolumeState 'Clear' -WritePolicy 'Deny' -PolicyPath 'HKLM:\FVE') -join "`n" + $lines | Should -Match 'Enable-BitLocker -MountPoint X:' + $lines | Should -Not -Match 'Run the script again' + } + + It 'rules the setting out no harder than it read it' { + # One key was read. That is enough to stop blaming the setting, not enough to acquit it. + $lines = (Resolve-WriteProtectionAdvice -MountPoint 'X:' -VolumeState 'Clear' -WritePolicy 'Allow' -PolicyPath 'HKLM:\FVE') -join "`n" + $lines | Should -Match 'does not report that setting as on' + $lines | Should -Match 'does not look like the cause' + $lines | Should -Not -Match 'almost certainly' + } + + It 'keeps the blame conditional only where the setting could not be read' { + $lines = (Resolve-WriteProtectionAdvice -MountPoint 'X:' -VolumeState 'Clear' -WritePolicy 'Unknown' -PolicyPath 'HKLM:\FVE') -join "`n" $lines | Should -Match 'may be set to deny' $lines | Should -Match 'If that setting is on' - $lines | Should -Not -Match 'is what makes it writable' + $lines | Should -Not -Match 'almost certainly' } - It 'rules that setting out when the drive is encrypted' { - $lines = (Resolve-WriteProtectionAdvice -MountPoint 'X:' -VolumeState 'Encrypted') -join "`n" - $lines | Should -Match 'does not explain this' + It 'sends an unread setting to the path it was given, and never to PolicyManager' { + # PolicyManager was empty on the machine that reported this; the effective path had the value. + $lines = (Resolve-WriteProtectionAdvice -MountPoint 'X:' -VolumeState 'Clear' -WritePolicy 'Unknown' -PolicyPath 'HKLM:\Somewhere') -join "`n" + $lines | Should -Match "Get-ItemProperty 'HKLM:\\Somewhere' -Name FDVDenyWriteAccess" $lines | Should -Not -Match 'PolicyManager' } + It 'assumes the setting was not read when none is passed' { + (Resolve-WriteProtectionAdvice -MountPoint 'X:' -VolumeState 'Clear' -PolicyPath 'HKLM:\FVE') -join "`n" | + Should -Match 'may be set to deny' + } + + It 'rules that setting out when the drive is encrypted, whatever the setting says' { + foreach ($policy in 'Deny', 'Allow', 'Unknown') { + $lines = (Resolve-WriteProtectionAdvice -MountPoint 'X:' -VolumeState 'Encrypted' -WritePolicy $policy -PolicyPath 'HKLM:\FVE') -join "`n" + $lines | Should -Match 'does not explain this' + $lines | Should -Not -Match 'That is the cause' + } + } + It 'narrows nothing down when the encryption state could not be read' { - $lines = (Resolve-WriteProtectionAdvice -MountPoint 'X:' -VolumeState 'Unknown') -join "`n" + $lines = (Resolve-WriteProtectionAdvice -MountPoint 'X:' -VolumeState 'Unknown' -PolicyPath 'HKLM:\FVE') -join "`n" $lines | Should -Match 'could not be read' $lines | Should -Not -Match 'deny write access' } It 'assumes nothing when no state is passed' { - (Resolve-WriteProtectionAdvice -MountPoint 'X:') -join "`n" | Should -Match 'could not be read' + (Resolve-WriteProtectionAdvice -MountPoint 'X:' -PolicyPath 'HKLM:\FVE') -join "`n" | Should -Match 'could not be read' } It 'quotes what Windows said when there is a message, and stays silent when there is not' { - (Resolve-WriteProtectionAdvice -MountPoint 'X:' -VolumeState 'Clear' -Reason 'Media is write-protected') -join "`n" | + (Resolve-WriteProtectionAdvice -MountPoint 'X:' -VolumeState 'Clear' -Reason 'Media is write-protected' -PolicyPath 'HKLM:\FVE') -join "`n" | Should -Match 'Windows said: Media is write-protected' - (Resolve-WriteProtectionAdvice -MountPoint 'X:' -VolumeState 'Clear') -join "`n" | + (Resolve-WriteProtectionAdvice -MountPoint 'X:' -VolumeState 'Clear' -PolicyPath 'HKLM:\FVE') -join "`n" | Should -Not -Match 'Windows said' } It 'offers the partition read-only check whatever BitLocker is doing' { foreach ($state in @('Clear', 'Encrypted', 'Unknown')) { - (Resolve-WriteProtectionAdvice -MountPoint 'X:' -VolumeState $state) -join "`n" | + (Resolve-WriteProtectionAdvice -MountPoint 'X:' -VolumeState $state -PolicyPath 'HKLM:\FVE') -join "`n" | Should -Match 'Get-Partition -DriveLetter X \|' } } It 'says the drive stays and nothing more can be set up, whatever the state' { foreach ($state in @('Clear', 'Encrypted', 'Unknown')) { - $lines = (Resolve-WriteProtectionAdvice -MountPoint 'X:' -VolumeState $state) -join "`n" + $lines = (Resolve-WriteProtectionAdvice -MountPoint 'X:' -VolumeState $state -PolicyPath 'HKLM:\FVE') -join "`n" $lines | Should -Match 'Drive X: stays as it is' $lines | Should -Match 'Nothing more can be set up' } } It 'returns plain lines rather than an object to unwrap' { - Resolve-WriteProtectionAdvice -MountPoint 'X:' -VolumeState 'Clear' | Should -BeOfType [string] + Resolve-WriteProtectionAdvice -MountPoint 'X:' -VolumeState 'Clear' -PolicyPath 'HKLM:\FVE' | + Should -BeOfType [string] } } diff --git a/dev_drive.ps1 b/dev_drive.ps1 index 1a88a45..858a4c2 100644 --- a/dev_drive.ps1 +++ b/dev_drive.ps1 @@ -293,7 +293,10 @@ function Add-VirtualDiskAttachment { } function Request-BitLockerChoice { - param([switch]$VhdxMode) + param( + [switch]$VhdxMode, + [string[]]$Notes + ) Write-Host "`nDo you want to enable BitLocker encryption for the Dev Drive?" -ForegroundColor Cyan Write-Host "BitLocker provides security but may impact performance." -ForegroundColor White @@ -301,6 +304,10 @@ function Request-BitLockerChoice { Write-Host "If the volume hosting the .vhdx file is itself encrypted, its contents are already" -ForegroundColor Yellow Write-Host "covered, and Microsoft does not recommend encrypting the virtual disk as well." -ForegroundColor Yellow } + # Printed before the menu, so a machine that makes encryption mandatory says so before the answer. + foreach ($note in $Notes) { + Write-Host $note -ForegroundColor Yellow + } Write-Host "1. Yes, enable BitLocker encryption" -ForegroundColor White Write-Host "2. No, skip BitLocker encryption" -ForegroundColor White Write-Host "" @@ -401,17 +408,76 @@ function Get-BitLockerAutoUnlockState { } } +function Get-FixedDriveWritePolicy { + <# Reads FDVDenyWriteAccess where it takes effect, whatever channel delivered it. Absent means + not set; Unknown means the read did not succeed, or answered something unreadable. #> + param([string]$Path = $script:FixedDriveWritePolicyPath) + + try { + if (-not (Test-Path -Path $Path -ErrorAction Stop)) { return 'Allow' } + $key = Get-ItemProperty -Path $Path -ErrorAction Stop + if ($null -eq $key.PSObject.Properties['FDVDenyWriteAccess']) { return 'Allow' } + + # A value of another type is an answer this cannot read, not an answer of "not set". + $value = $key.FDVDenyWriteAccess + if ($value -isnot [int] -and $value -isnot [long]) { return 'Unknown' } + if ($value -eq 1) { return 'Deny' } + return 'Allow' + } + catch { + return 'Unknown' + } +} + +function Resolve-WriteAccessPolicyAdvice { + <# What the setting means for this run: without BitLocker such a machine mounts the Dev Drive + read-only. -Skipping is for the plan, where the answer is already known. #> + param( + [Parameter(Mandatory)][ValidateSet('Deny', 'Allow', 'Unknown')][string]$Policy, + [string]$PolicyPath = $script:FixedDriveWritePolicyPath, + [switch]$Skipping + ) + + if ($Policy -eq 'Allow') { + return @() + } + + if ($Policy -eq 'Unknown') { + $lines = @("This machine's setting on unencrypted fixed drives could not be read, so it is not known whether the Dev Drive will accept writes without BitLocker.") + $lines += "Read it with: Get-ItemProperty '$PolicyPath' -Name FDVDenyWriteAccess -ErrorAction SilentlyContinue" + return $lines + } + + $lines = @("This machine denies write access to fixed drives that BitLocker does not protect (FDVDenyWriteAccess is 1).") + if ($Skipping) { + $lines += "So this Dev Drive will mount read-only, and nothing can be written to it." + $lines += "The run will stop at the write check once the drive exists. Enable BitLocker, or change that setting first." + } else { + $lines += "So a Dev Drive without BitLocker would mount read-only here, with nothing able to be written to it." + } + return $lines +} + function Resolve-BitLockerSetupPlan { <# Decides which protectors this machine can carry, and the lines explaining why. #> param( [switch]$DomainJoined, [switch]$EntraJoined, [switch]$VhdxMode, - [switch]$OsDriveProtected + [switch]$OsDriveProtected, + [ValidateSet('Deny', 'Allow', 'Unknown')][string]$WritePolicy = 'Allow' ) $notes = @() + # Warned on Unknown too: a read that failed is not evidence that the setting is off. + if ($WritePolicy -eq 'Deny') { + $notes += "Until this finishes the drive is read-only, because this machine denies writes to fixed drives BitLocker does not protect." + } + if ($WritePolicy -ne 'Allow') { + $notes += "Windows may put up its own prompt to encrypt the drive while this runs. Leave it alone - this run is already encrypting, and answering it only produces an error." + } + if ($VhdxMode) { $notes += "The Dev Drive lives in a virtual hard disk, so a BitLocker password will be asked for: it unlocks the volume after the file is mounted." } else { @@ -1896,7 +1962,9 @@ function Resolve-WriteProtectionAdvice { param( [Parameter(Mandatory)][string]$MountPoint, [ValidateSet('Encrypted', 'Clear', 'Unknown')][string]$VolumeState = 'Unknown', - [AllowNull()][AllowEmptyString()][string]$Reason + [AllowNull()][AllowEmptyString()][string]$Reason, + [ValidateSet('Deny', 'Allow', 'Unknown')][string]$WritePolicy = 'Unknown', + [string]$PolicyPath = $script:FixedDriveWritePolicyPath ) # Offered in every branch: a partition carrying the read-only flag refuses writes whatever @@ -1909,10 +1977,17 @@ function Resolve-WriteProtectionAdvice { } if ($VolumeState -eq 'Clear') { - $lines += "The drive is not encrypted, and this machine may be set to deny write access to fixed drives that BitLocker does not protect." - $lines += "If that setting is on, Windows mounts every unencrypted fixed data drive read-only, and finishing BitLocker on $MountPoint would make it writable." - $lines += "Read it on a machine managed from the cloud with: Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\PolicyManager\current\device\BitLocker' -ErrorAction SilentlyContinue" - $lines += "Read it on a machine managed by group policy with: Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\FVE' -ErrorAction SilentlyContinue" + # The setting was read before the plan, so this names a likely cause instead of listing suspects. + if ($WritePolicy -eq 'Deny') { + $lines += "This machine denies write access to fixed drives that BitLocker does not protect, and $MountPoint is not encrypted. That is almost certainly the cause." + $lines += "Encrypt this volume to make it writable, without creating it again: Enable-BitLocker -MountPoint $MountPoint -RecoveryPasswordProtector -UsedSpaceOnly" + } elseif ($WritePolicy -eq 'Allow') { + $lines += "The drive is not encrypted, but this machine does not report that setting as on, so it does not look like the cause." + } else { + $lines += "The drive is not encrypted, and this machine may be set to deny write access to fixed drives that BitLocker does not protect." + $lines += "If that setting is on, Windows mounts every unencrypted fixed data drive read-only, and encrypting $MountPoint would make it writable." + $lines += "Read it with: Get-ItemProperty '$PolicyPath' -Name FDVDenyWriteAccess -ErrorAction SilentlyContinue" + } $lines += $partitionCheck } elseif ($VolumeState -eq 'Encrypted') { $lines += "The drive is encrypted, so the setting that mounts unencrypted drives read-only does not explain this." @@ -1947,6 +2022,10 @@ if ($windows_build -ge $windows_build_min) { # Microsoft's documented minimum size for a Dev Drive volume (https://learn.microsoft.com/en-us/windows/dev-drive/) $DevDriveMinSizeGB = 50 +# Where "deny write access to fixed drives not protected by BitLocker" takes effect. PolicyManager +# was empty on the machine that reported this, so the effective path is the one to read. +$FixedDriveWritePolicyPath = 'HKLM:\SYSTEM\CurrentControlSet\Policies\Microsoft\FVE' + # The name offered on the Enter key. $DevDriveDefaultLabel = "DevDrive" @@ -2147,8 +2226,12 @@ if ($mode -eq "FreeSpace") { # Asked here rather than in each mode: the name is the same question whatever created the volume. $DevDriveLabel = Request-DevDriveLabel -Default $DevDriveDefaultLabel -MaxLength $DevDriveLabelMaxLength +# Read before the question, not after: on a machine with this setting the answer is not a preference. +$WritePolicy = Get-FixedDriveWritePolicy + # Ask about BitLocker encryption -$enableBitLocker = Request-BitLockerChoice -VhdxMode:($mode -eq "Vhdx") +$enableBitLocker = Request-BitLockerChoice -VhdxMode:($mode -eq "Vhdx") ` + -Notes (Resolve-WriteAccessPolicyAdvice -Policy $WritePolicy) $SkipBitLocker = -not $enableBitLocker # Settled before the plan is shown; a fact that cannot be read counts as the safe answer. @@ -2182,7 +2265,7 @@ if ($enableBitLocker) { } $bitLockerPlan = Resolve-BitLockerSetupPlan -DomainJoined:$isDomainJoined -EntraJoined:$isEntraJoined ` - -VhdxMode:($mode -eq "Vhdx") -OsDriveProtected:$osDriveProtected + -VhdxMode:($mode -eq "Vhdx") -OsDriveProtected:$osDriveProtected -WritePolicy $WritePolicy } # Ask about deduplication @@ -2247,6 +2330,10 @@ if (-not $SkipBitLocker) { } } else { Write-Host "* Skip BitLocker encryption" -ForegroundColor White + # Said here, before anything exists, rather than left for the write check to discover afterwards. + foreach ($line in (Resolve-WriteAccessPolicyAdvice -Policy $WritePolicy -Skipping)) { + Write-Host " - $line" -ForegroundColor Yellow + } } if (-not $SkipDeduplication) { @@ -2600,7 +2687,8 @@ try { } else { $encryptionState = (Get-BitLockerProtectionState -MountPoint $devLetterColon).Label Write-Host "" - foreach ($line in (Resolve-WriteProtectionAdvice -MountPoint $devLetterColon -VolumeState $encryptionState -Reason $writeState.Reason)) { + foreach ($line in (Resolve-WriteProtectionAdvice -MountPoint $devLetterColon -VolumeState $encryptionState ` + -Reason $writeState.Reason -WritePolicy $WritePolicy)) { Write-Host $line -ForegroundColor Red } # Thrown rather than exited: the closing advice about a shrunk drive and a left-behind .vhdx lives in the catch.