End-to-end deployment of BitLockerKeyMonitor to a Windows Server using the two helper scripts shipped with the repository:
tools/Setup-BitLockerMonitorGmsa.ps1— provisions the gMSA service account and all required permissions (AD, SQL, certs, services).Deploy-Remote.ps1— publishes the .NET solution from the build machine and deploys it to the target server via WinRM.
┌─────────────────┐ WinRM ┌──────────────────────────────────┐
│ Build machine │ ─────────────▶ │ Target server (SRVBLKMON01) │
│ (not domain │ │ ┌────────────────────────────┐ │
│ joined OK) │ │ │ Worker service (Quartz) │ │
└─────────────────┘ │ │ Web service (Kestrel, 443) │ │
│ │ SQL Server Express (local) │ │
│ │ Both services run as gMSA │ │
│ └────────────────────────────┘ │
└──────────────────────────────────┘
│
│ Kerberos (gMSA)
▼
┌──────────────────────────────────┐
│ Domain Controller (LDAP/ADWS) │
│ msFVE-RecoveryInformation │
└──────────────────────────────────┘
- .NET 10 SDK
- PowerShell 5.1+
- WinRM connectivity to the target (typically TCP 5985 over the management VLAN)
- A credential with local administrator rights on the target server
The build machine does NOT need to be domain-joined — it connects to the target via WinRM with explicit credentials.
- Windows Server 2022+, domain-joined
- .NET 10 ASP.NET Core Runtime (Hosting Bundle)
- SQL Server Express (local) — or a remote SQL instance with the equivalent grants
- WinRM enabled (default on domain-joined servers)
- TLS certificate in
LocalMachine\My(for Kestrel HTTPS) - RSAT ActiveDirectory module (
Add-WindowsCapability -Online -Name Rsat.ActiveDirectory.DS-LDS.Tools~~~~0.0.1.0) — required forInstall-ADServiceAccount
- A Domain Admin account (or equivalent) able to:
- Create gMSA accounts and assign
PrincipalsAllowedToRetrieveManagedPassword - Modify ACLs on the OUs that hold BitLocker recovery key objects
- Register SPNs on the gMSA
- Create gMSA accounts and assign
- A KDS Root Key exists, or you can create one (
Add-KdsRootKey -EffectiveImmediately)
For the Entra scan to work, the App Registration linked to Authentication.ClientId in appsettings.json needs these Application permissions (admin consent required):
| Permission | Purpose |
|---|---|
Device.Read.All |
Enumerate devices and correlate with AD computer objects |
BitlockerKey.ReadBasic.All |
List recovery keys (KeyId, VolumeId, CreatedDate) |
BitlockerKey.Read.All |
Read the actual recovery password value (getKey) — only needed if IncludeRecoveryKeyValue=true |
Verify the granted permissions by decoding an access token issued to the app (the roles claim is the source of truth — see Troubleshooting).
Run this script once per environment, from a domain-joined administrative workstation (or a DC). The build machine does not need to be domain-joined.
$gmsaParams = @{
TargetServer = "SRVBLKMON01"
GmsaName = "svc-BlkMon"
DomainDnsName = "contoso.local"
SearchBaseOUs = @("OU=Workstations,DC=contoso,DC=local")
SqlInstance = ".\SQLEXPRESS"
DatabaseName = "BitLockerKeyMonitor"
CertificateThumbprints = @(
"ae803d83c7326852729aad9da891d8df17a6c549", # Kestrel HTTPS cert
"855cdf6182de9cbd6c7d3c95340b5caa7222d2ba" # Entra client auth cert
)
}
.\tools\Setup-BitLockerMonitorGmsa.ps1 @gmsaParamsFor lab/test environments where you do not want to wait the 10-hour KDS replication window, add -LabMode:
.\tools\Setup-BitLockerMonitorGmsa.ps1 @gmsaParams -LabMode| # | Step | Detail |
|---|---|---|
| 1 | KDS Root Key | Creates one if missing (-LabMode skips the 10h wait) |
| 2 | Create gMSA | svc-BlkMon$ with $TargetServer in PrincipalsAllowedToRetrieveManagedPassword |
| 3 | AD ACLs on each OU | • GenericRead on msFVE-RecoveryInformation descendants• ReadProperty on msFVE-RecoveryPassword• ExtendedRight on msFVE-RecoveryPassword (required for confidential attribute) |
| 3b | HTTP SPNs | HTTP/<server> + HTTP/<server.fqdn> on the gMSA (required for Negotiate/Kerberos on the Web) |
| 4 | Install gMSA | Install-ADServiceAccount + Test-ADServiceAccount on the target |
| 5 | SQL login | Creates MSLABS\svc-BlkMon$ login and assigns db_owner on the application DB |
| 6 | "Log on as a service" right | Grants SeServiceLogonRight via secedit |
| 7 | Certificate private key ACLs | Adds Read on each cert's private key file in C:\ProgramData\Microsoft\Crypto\Keys |
| 8 | Service configuration | Configures Worker and Web services to run as the gMSA |
msFVE-RecoveryPassword is a confidential attribute in AD (searchFlags bit 0x80).
AD silently omits the value from LDAP query results unless the caller has the
ExtendedRight (Control Access) right — ReadProperty alone is not enough.
The script grants both. If you set ACLs manually (e.g. through ADUC delegation),
make sure the Control Access right on msFVE-RecoveryPassword is also delegated,
otherwise the AD scan will run successfully but every KeyValue will be NULL and
the consistency check will report ValueComparisonIncomplete.
After the script completes:
# On the target server
Get-Service BitLockerKeyMonitor.Worker, BitLockerKeyMonitor.Web
Test-ADServiceAccount -Identity svc-BlkMon
Get-WmiObject Win32_Service -Filter "Name='BitLockerKeyMonitor.Worker'" |
Select-Object Name, State, StartName
# StartName should read DOMAIN\svc-BlkMon$The Deploy-Remote.ps1 script copies the appsettings.json from the source tree as-is.
Before publishing, edit both src/BitLockerKeyMonitor.Worker/appsettings.json and
src/BitLockerKeyMonitor.Web/appsettings.json with values appropriate for the target:
Important values:
Authentication.AdAuthMode = "WindowsIntegrated"— required when running as gMSA. Do not setCredentialmode (the gMSA has no static password).WebServer.CertificateThumbprint— must reference a cert whose private key the gMSA can read (already handled by-CertificateThumbprintsin Step 1).
Run from the repository root on the build machine.
$cred = Get-Credential # local admin on the target server
.\Deploy-Remote.ps1 -TargetServer SRVBLKMON01 -Credential $cred| # | Step | Detail |
|---|---|---|
| 1 | Pre-flight | Verifies source projects exist; pings the target |
| 2 | Publish Worker | dotnet publish -c Release to artifacts\Worker (skip with -SkipPublish) |
| 3 | Publish Web | dotnet publish -c Release to artifacts\Web |
| 4 | PSSession | Opens a WinRM session to the target |
| 5 | Stop services | Stops Web first, then Worker |
| 6 | Copy artifacts | Copies the published trees into C:\Program Files\BitLockerKeyMonitor\{Worker,Web} |
| 7 | Register services | Creates/updates the two Windows Services and sets MSSQL$SQLEXPRESS as a dependency |
| 8 | Start services | Starts Worker, then Web; verifies both are Running |
| Parameter | Default | Purpose |
|---|---|---|
-TargetServer |
(required) | Hostname or FQDN |
-Credential |
prompts | Local admin on the target |
-InstallRoot |
C:\Program Files\BitLockerKeyMonitor |
Install path |
-SqlInstanceName |
SQLEXPRESS |
Local SQL instance for service dependency |
-SkipPublish |
off | Reuse existing artifacts\ (faster re-deploy) |
-SkipServiceRestart |
off | Copy bits but leave services stopped (maintenance window) |
The script overwrites appsettings.json on the target at every deploy
(it copies the published artifact verbatim). After a deploy you typically need to:
- Restore environment-specific values like
WebServer.CertificateThumbprintand any values you do not want to commit to source control, or - Use the recommended pattern of keeping environment overrides in a separate
appsettings.Production.jsonthat is not in source control and is placed manually on the target after the first deploy.
A future iteration could add an -EnvSettingsPath parameter that overlays an
out-of-tree config file after each copy.
From the target server:
# Services running as the gMSA
Get-WmiObject Win32_Service -Filter "Name LIKE 'BitLockerKeyMonitor%'" |
Select-Object Name, State, StartName
# Wait for the next scheduled scan (default: every 5 minutes), then check the log
Get-Content "C:\ProgramData\BitLockerKeyMonitor\logs\worker-$(Get-Date -Format yyyyMMdd).log" -Tail 30Healthy output ends with something like:
=== Scan Completed in 00:00:02.4 | AD: 5 devices/16 keys | Entra: 9 devices/5 keys | Consistent: 3, Inconsistent: 2 ===
Then visit the Web portal:
https://<server.fqdn>/
You should be challenged for Windows credentials and authenticated via Kerberos. The portal lands on the dashboard with the latest scan results.
The most likely cause is that the gMSA does not have ExtendedRight on
msFVE-RecoveryPassword. Re-run the gMSA script — Step 3 is idempotent and
will add the missing ACE.
Verify the DB after the next scan:
USE BitLockerKeyMonitor;
DECLARE @scan INT = (SELECT MAX(Id) FROM ScanRuns);
SELECT d.DnsHostName, k.KeyId, k.Source,
CASE WHEN k.KeyValue IS NULL THEN 'NULL' ELSE 'len=' + CAST(LEN(k.KeyValue) AS VARCHAR) END AS Val
FROM RecoveryKeys k JOIN Devices d ON d.Id = k.DeviceId
WHERE k.ScanRunId = @scan AND k.Source = 0 AND k.KeyValue IS NULL;
-- If this returns rows, the AD ACL is still incomplete.The HTTP SPNs are missing on the gMSA. Verify with:
Get-ADServiceAccount svc-BlkMon -Properties servicePrincipalName |
Select-Object -ExpandProperty servicePrincipalNameIf HTTP/<server> and HTTP/<server>.<domain> are not listed, re-run the gMSA
script (Step 3b is idempotent) or add them manually:
Set-ADServiceAccount svc-BlkMon -ServicePrincipalNames @{
Add = "HTTP/SRVBLKMON01","HTTP/SRVBLKMON01.contoso.local"
}Two common causes:
- gMSA missing "Log on as a service" right — re-run Step 6 of the gMSA script.
- SQL login missing or insufficient grants — re-run Step 5 (creates the login and adds
db_owner).
Check the Application event log on the target for the specific error message.
The App Registration is missing the required Graph permission. Verify by acquiring a
token with the configured certificate and decoding the JWT — the roles claim must
include BitlockerKey.Read.All for value retrieval to work. See the script in
docs/RECOVERY_KEY_VAULT.md (or use any JWT decoder).
To grant via Azure CLI:
# BitlockerKey.Read.All app role ID = 33854624-cf6e-44b8-94cf-c4d6e6dad88f
az ad app permission add --id <client-id> \
--api 00000003-0000-0000-c000-000000000000 \
--api-permissions 33854624-cf6e-44b8-94cf-c4d6e6dad88f=Role
az ad app permission admin-consent --id <client-id># Verify connectivity from the build machine
Test-WSMan -ComputerName SRVBLKMON01 -Credential $cred -Authentication NegotiateIf the build machine is not domain-joined, add the target to TrustedHosts:
Set-Item WSMan:\localhost\Client\TrustedHosts -Value "SRVBLKMON01" -ForceOnce the gMSA is provisioned, day-to-day deployments are a single command:
.\Deploy-Remote.ps1 -TargetServer SRVBLKMON01 -Credential $credThe gMSA script only needs to be re-run when:
- Changing the gMSA account name or target server
- Adding new search OUs for BitLocker recovery keys
- Rotating the Entra or Kestrel certificate (to grant private-key access to the new cert)
- After a clean re-install of the target server
{ "ConnectionStrings": { "DefaultConnection": "Server=.\\SQLEXPRESS;Database=BitLockerKeyMonitor;Trusted_Connection=True;TrustServerCertificate=True;" }, "Authentication": { "AdAuthMode": "WindowsIntegrated", // gMSA uses Kerberos automatically "EntraAuthMode": "ClientCertificate", "TenantId": "<your-tenant-guid>", "ClientId": "<your-app-client-id>", "CertificateThumbprint": "855cdf61..." // matches Entra App Registration }, "ScanSettings": { "LdapServer": "dc01.contoso.local", "LdapSearchBase": "OU=Workstations,DC=contoso,DC=local", "LdapPort": 389, "LdapUseSsl": false }, "WebServer": { "HttpsPort": 443, "CertificateThumbprint": "ae803d83..." // Kestrel HTTPS cert (Web only) }, "Authorization": { "AdminGroup": "CONTOSO\\BitLockerMonitor-Admins", "ViewerGroup": "CONTOSO\\BitLockerMonitor-Viewers" } }