Let BitLocker judge the password, and say what it will want - #104
Merged
Conversation
Measured on a scratch .vhdx with no policy set: BitLocker takes eight lowercase letters, a Cyrillic letter, an accented Latin letter, an Arabic-Indic digit and a tab character. It refuses seven characters with 0x80310080 and 257 with 0x803100AA, and nothing else. So five of the seven rules the pre-check applied were this script's own invention, and the printable-ASCII one refused passwords Windows accepts - there is no ASCII policy for a fixed data drive at all, only for the operating system drive. Resolve-UnmetPasswordRequirement and Request-StrongPassword are gone, with their two constants and two dozen tests. Read-Host -AsSecureString now hands the SecureString straight to Add-BitLockerKeyProtector, so the plaintext copy the last change worked to free never exists. The retry loop lost its ceiling of ten: a refused password is retried for as long as somebody keeps typing one. That made the classification load-bearing, and it was wrong. The "a password was asked for" flag came from the plan, which is true for the whole run, while the prompt itself is skipped once the volume carries a password protector. A failure carrying a password code on a pass that prompts for nothing would have looped forever at full speed. The flag is now set where the Read-Host is, and a test pins that through the syntax tree. The prompt says what this machine will want, read from FDVPassphrase, FDVPassphraseLength and FDVPassphraseComplexity - names taken from VolumeEncryption.admx. Both registry branches are read because which one BitLocker honours has not been seen, and two that disagree answer "unknown" rather than picking one. A floor below BitLocker's own eight is clamped: the policy editor holds that value to 8..99, the registry holds it to nothing. Complexity is never claimed to be checked where it cannot be. The filter that would check it lives on domain controllers, so the note says only who might want it, and says the password is taken as typed where none can be reached. Closes #102 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #102.
The pre-check was mostly invented
Measured on a scratch
.vhdxwith no BitLocker policy set and FIPS off, by callingAdd-BitLockerKeyProtector -PasswordProtectorwith fourteen candidates. A password protector can be added to aFullyDecryptedvolume, which is what this script does, so no encryption pass was needed.0x803100800x803100AASo of the seven rules the script applied, two were BitLocker's and five were this script's own. The printable-ASCII rule was worse than merely invented — it refused passwords Windows accepts.
C:\Windows\PolicyDefinitions\VolumeEncryption.admxsays why: the whole file mentions ASCII once, asOSPassphraseASCIIOnly, inside the operating system drive policy. A fixed data drive has no such option.Mirroring BitLocker's rules instead does not work
There is no way to ask BitLocker whether it would accept a password:
Win32_EncryptableVolumehas 73 methods and none validates a passphrase,ProtectKeyWithPassPhrasehas no check-only flag, andfveapi.dll'sFveCheckPassphrasePolicyis real but has no header or import library in any Windows Kit, so its signature is not knowable.And complexity cannot be checked locally at all. From the same policy file, verbatim:
So BitLocker judges it
Resolve-UnmetPasswordRequirementandRequest-StrongPasswordare gone with their two constants and about two dozen tests.Read-Host -AsSecureStringhands theSecureStringstraight toAdd-BitLockerKeyProtector -Password, which takes one.The plaintext copy no longer exists. The previous change went to some length to free the unmanaged copy and prove it; with no rules to apply there is nothing to convert. No BSTR, no
ZeroFreeBSTR, no$plain.The retry was already written:
Resolve-BitLockerFailurematches the four password codes, quotes what Windows said, and treats0x8031006Aand0x8031006Cas refusals a retry would only repeat.No ceiling on the attempts, and the bug that made that dangerous
The loop stopped after ten attempts. It no longer stops: a refused password is retried for as long as somebody keeps typing one, and Ctrl+C is theirs to press. It is deliberately not advertised at the prompt — prompts after creation begins do not offer leaving.
Removing the ceiling made the failure classification load-bearing, and it was wrong. Found by review, and it is the most important thing in this pull request.
The flag saying "a password was asked for" came from the plan, which is true for the whole run in
.vhdxmode. The prompt itself is gated on something else: the volume not already carrying a password protector. So a failure carrying a password code, arising on a pass that prompts for nothing, was classified as retryable and sent straight round again — with noRead-Hostanywhere on the path. The ceiling had been the only thing bounding that, and the comment claiming every pass waits on a person was asserted by nothing.The flag is now set where the
Read-Hostis, per pass. A test walks the syntax tree for it: one prompt, one verdict call, the argument not the plan's variable, set$falseat the top of the pass and$trueonly after the prompt.The attempt number was also counting every failure in the loop, so "Attempt 3" could greet a first-time typist. It counts prompts now.
The prompt says what this machine will want
Read from
FDVPassphrase,FDVPassphraseLengthandFDVPassphraseComplexity— names fromVolumeEncryption.admx— to word the note, never to enforce anything.and where policy asks for fifteen,
Group policy on this machine asks for at least 15 characters. Windows takes at most 256.Four things that read is not allowed to get wrong, each with its own test:
FDVPassphrasethat is off or absent was not written by group policy.Complexity is never claimed to be checked where it cannot be: where policy requires it the note says only a domain controller can check that and the password is taken as typed where none can be reached; where policy merely allows it, the note appears only on a domain-joined machine; where policy turns the check off, nothing is said.
Tests
750 to 779, and about two dozen deleted along the way. Sixteen mutations, each the exact defect one assertion exists to catch, each caught, the script restored byte for byte every time:
The last one fails everything because the harness refuses to run against a script whose constant it cannot find — better than silently testing a path the run does not use.
The policy reader is covered by mocks rather than by writing to a real hive, which is what #85 asks for of its neighbour.
Reviewed
Sixteen findings, all applied. Besides the classification bug above:
FDVPassphrasewas ignored entirely; a policy floor below eight was printed verbatim; an unreadable complexity value was hidden whenever a length had read; the two branches could each supply a different field, producing a policy that existed in neither; "which a domain controller checks" was untrue on a machine without one; the numbers 8 and 256 had become bare literals in three sentences after the constants that held them were deleted; the new reader duplicated its neighbour'sTest-Path/Get-ItemProperty/ type-check / catch pattern, now a sharedGet-PolicyDwordValue; the default-Paths, the call site and both branch paths were uncovered; and two comments described the code as it used to be.Two suggestions taken only in part. Making
-RetryCountoptional was declined — it is now a real password-attempt number rather than a count of everything, and being mandatory keeps it that way. And a branch that removing the ceiling made unreachable was kept and commented as defensive rather than deleted: dropping it would assert that no password refusal can ever be final, which is not something measured here.Behaviour change, stated plainly
Eight lowercase letters will be accepted for an encrypted drive, because Windows accepts them. The prompt has said "It must be a complex one" since the first version and that sentence is gone.
README.mdsays what is enforced and by whom.The empty password, measured
Somebody pressing Enter at the prompt.
Read-Host -AsSecureStringanswers aSecureStringof length 0, and the question was whether that reaches BitLocker at all or fails parameter binding first - in which case the commonest typo would carry no0x8031code and the retry could not recognise it.Measured 2026-08-26 on a scratch
.vhdx, and identical for an emptySecureString, a single space and one character:So it reaches BitLocker, comes back with the length code, and the existing path sorts it as a password refusal and asks for another. Nothing to change.
🤖 Generated with Claude Code