Skip to content

Let BitLocker judge the password, and say what it will want - #104

Merged
ALERTua merged 1 commit into
mainfrom
bugfix/102
Aug 27, 2026
Merged

Let BitLocker judge the password, and say what it will want#104
ALERTua merged 1 commit into
mainfrom
bugfix/102

Conversation

@ALERTua

@ALERTua ALERTua commented Aug 27, 2026

Copy link
Copy Markdown
Owner

Closes #102.

The pre-check was mostly invented

Measured on a scratch .vhdx with no BitLocker policy set and FIPS off, by calling Add-BitLockerKeyProtector -PasswordProtector with fourteen candidates. A password protector can be added to a FullyDecrypted volume, which is what this script does, so no encryption pass was needed.

candidate BitLocker
eight lowercase letters, nothing else accepted
eleven lowercase letters, nothing else accepted
seven characters refused 0x80310080
Cyrillic letters, digits and a symbol accepted
ASCII password plus a Cyrillic letter, an accented Latin letter, an Arabic-Indic digit, or a tab accepted
128 / 255 / 256 characters accepted
257 and 1000 characters refused 0x803100AA

So 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.admx says why: the whole file mentions ASCII once, as OSPassphraseASCIIOnly, 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_EncryptableVolume has 73 methods and none validates a passphrase, ProtectKeyWithPassPhrase has no check-only flag, and fveapi.dll's FveCheckPassphrasePolicy is 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:

Because the password filter used to validate password complexity is located on the domain controllers of the domain, local user accounts will not be able to access the password filter

So BitLocker judges it

Resolve-UnmetPasswordRequirement and Request-StrongPassword are gone with their two constants and about two dozen tests. Read-Host -AsSecureString hands the SecureString straight to Add-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-BitLockerFailure matches the four password codes, quotes what Windows said, and treats 0x8031006A and 0x8031006C as 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 .vhdx mode. 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 no Read-Host anywhere 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-Host is, per pass. A test walks the syntax tree for it: one prompt, one verdict call, the argument not the plan's variable, set $false at the top of the pass and $true only 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, FDVPassphraseLength and FDVPassphraseComplexity — names from VolumeEncryption.admx — to word the note, never to enforce anything.

Enter a BitLocker password for the new volume.
Windows takes at least 8 characters and at most 256. Group policy can ask for more than 8.

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:

  • Both registry branches are read. Which one BitLocker honours has not been established, so two that disagree answer "unknown" rather than picking one.
  • A figure without its switch is not a policy. Group policy writes the three values together, so a length under a FDVPassphrase that is off or absent was not written by group policy.
  • A floor below BitLocker's own eight is clamped. The policy editor holds that value to 8..99; the registry holds it to nothing, and "at least 4" would be refused by Windows quoting eight back.
  • "Could not be read" is never reported as "nothing is set."

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:

real script                                              -> 779 passed, 0 failed
the classification goes back to the run-wide flag        -> 1 failed
the per-pass flag is never raised                        -> 1 failed
the loop gets a ceiling again                            -> 1 failed
the refusal names an attempt ceiling again               -> 3 failed
the prompt starts listing rules again                    -> 2 failed
the password is turned back into a plain string          -> 1 failed
the machine notes are not printed at all                 -> 1 failed
the plan is no longer told what the policy says          -> 1 failed
the policy floor is no longer clamped to BitLocker's own -> 2 failed
an unreadable policy is reported as nothing being set    -> 1 failed
a required complexity is downgraded to a maybe           -> 2 failed
the note is given even where policy turns the check off  -> 1 failed
a figure under a policy that is off is believed          -> 3 failed
two branches that disagree are not noticed               -> 1 failed
a wrong-typed policy value reads as a number             -> 1 failed
only one registry branch is read                         -> 779 failed

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: FDVPassphrase was 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's Test-Path / Get-ItemProperty / type-check / catch pattern, now a shared Get-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 -RetryCount optional 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.md says what is enforced and by whom.

The empty password, measured

Somebody pressing Enter at the prompt. Read-Host -AsSecureString answers a SecureString of 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 no 0x8031 code and the retry could not recognise it.

Measured 2026-08-26 on a scratch .vhdx, and identical for an empty SecureString, a single space and one character:

exception type : System.Runtime.InteropServices.COMException
error id       : System.Runtime.InteropServices.COMException,Add-PasswordProtectorInternal
HResult        : 0x80310080
message        : Your password does not meet minimum password length requirements. By default,
                 passwords must be at least 8 characters in length. Check with your system
                 administrator for the password length requirement in your organization.

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

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>
@ALERTua
ALERTua merged commit 5ac360d into main Aug 27, 2026
3 checks passed
@ALERTua
ALERTua deleted the bugfix/102 branch August 27, 2026 15:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Stop pre-checking the password: hand it to BitLocker and report what it says

1 participant