Series · BitLocker Key Monitor: proving recovery keys exist · Part 3 of 3
Security Architecture · Encryption · SQL Server · BitLocker
Encrypting the recovery keys themselves: row-bound AES-GCM and honest limits
Part 3 of 3. How recovery keys are protected at rest with per-row authenticated encryption and wrapped data keys, what that does and does not defend against, and the lessons we would pass on.
- Drafted
- Published
- Reading time
- 5 min
- Author
- Roberto Gramellini
Source:
BitLocker Key Monitor
· revision 6390107e
- encryption at rest
- AES-GCM
- AAD
- envelope encryption
- Always Encrypted
- TDE
- data encryption key
- key wrapping
- recovery mnemonic
- threat model
A database full of BitLocker recovery keys is, bluntly, the most attractive table in your estate. In Part 2 the keys travelled safely to Azure. Now they have to rest somewhere, and "somewhere" is a SQL database that a privileged person can query.
This last part is about the layers we added, why each one exists, and, because good engineering notes include what does not work, the limits.
Layers, and what each one is for
Figure 1. Each layer answers a different attacker. The dashed one is a recommendation, not a claim.
TDE protects files and backups. It does nothing against someone who can run SELECT on a live database. So the on-premises mode adds envelope encryption on top: each recovery value is encrypted before it is written, and the row itself becomes part of the cryptographic proof.
Binding ciphertext to its own row
The encrypt path is short enough to read in one go, and the detail worth noticing is the associated data:
// src\BitLockerKeyMonitor.Core\Security\RecoveryKeyStorageEncryptor.cs (lines 73-98) if (string.IsNullOrEmpty(plaintext)) return plaintext; if (dek.Length != DataKeyWrapper.DekSize) throw new ArgumentException($"DEK must be {DataKeyWrapper.DekSize} bytes", nameof(dek)); var plainBytes = Encoding.UTF8.GetBytes(plaintext); var nonce = new byte[NonceSize]; RandomNumberGenerator.Fill(nonce); var ciphertext = new byte[plainBytes.Length]; var tag = new byte[TagSize]; var aad = BuildRowAad(deviceId, source, keyId); using var aes = new AesGcm(dek, TagSize); aes.Encrypt(nonce, plainBytes, ciphertext, tag, aad); var combined = new byte[NonceSize + TagSize + ciphertext.Length]; Buffer.BlockCopy(nonce, 0, combined, 0, NonceSize); Buffer.BlockCopy(tag, 0, combined, NonceSize, TagSize); Buffer.BlockCopy(ciphertext, 0, combined, NonceSize + TagSize, ciphertext.Length); return Prefix + Convert.ToBase64String(combined);
The stored value is bkmdb:v1$ followed by base64(nonce[12] || tag[16] || ciphertext), and the AAD is built from the device, the source and the key identifier. Paste a ciphertext from one device's row into another and GCM refuses to authenticate it. A database dump is no longer a key dump, and a swap attack becomes a loud error instead of a silent wrong answer.
The decrypt path is deliberately unforgiving as well. Truncated input is rejected up front, and an authentication failure surfaces as a CryptographicException rather than a "best effort" empty string:
// same file (lines 105-108) var b64 = value[Prefix.Length..]; var combined = Convert.FromBase64String(b64); if (combined.Length < NonceSize + TagSize) throw new CryptographicException("DB row ciphertext is truncated.");
Who protects the protector?
Every row is encrypted with a per-installation data encryption key (DEK). The DEK is never stored in the clear. It is wrapped twice, in two independent slots, so that either secret can open it:
Figure 2. One DEK, two ways in. Each wrap is authenticated for its own purpose.
// src\BitLockerKeyMonitor.Core\Security\DataKeyWrapper.cs (lines 80-90) /// <summary> /// AAD bound to a slot identifier. Different bytes per slot so a passphrase-slot wrap /// cannot be swapped to the recovery slot (AES-GCM tag would not verify). /// </summary> private static byte[] BuildSlotAad(WrapSlot slot) => slot switch { WrapSlot.Passphrase => "bkm-dek-wrap-v1|slot=passphrase"u8.ToArray(), WrapSlot.Recovery => "bkm-dek-wrap-v1|slot=recovery"u8.ToArray(), _ => throw new ArgumentOutOfRangeException(nameof(slot)) };
The same trick as the rows, applied one level up: a valid wrap from one slot cannot be substituted into the other.
Multiple processes (the worker and the web portal) share one DEK, so they must agree on which DEK. A background reconciler polls every 30 seconds and compares fingerprints; if the portal rotates or resets the key, the worker notices the drift, re-unlocks, and fails soft into degraded mode (scans fail closed) rather than writing with a stale key.
The honest part: limits and scars
- TDE is not enough against a privileged SQL reader. The documentation says so plainly and names Always Encrypted with Key Vault-managed keys as the recommended next control. I would rather ship that sentence than pretend.
- Bootstrapping encryption is a ceremony. Services stopped, local admin rights, a one-time 24-word recovery mnemonic. Lose it and you have built a very secure paperweight. It is annoying by design.
- Legacy plaintext rows stay readable during migration while new rows are encrypted. Migrations are never as tidy as the diagram.
- It is single-tenant on purpose. A multi-tenant model would need per-tenant data-encryption keys, and claiming otherwise would be marketing.
Numbers, because adjectives are cheap
| Item | Value |
|---|---|
| Worker compliance re-evaluation | every 5 minutes |
| Azure coordinator cycle | every 1 minute |
| Heartbeats | every 30 seconds |
| Intake request limit | 24 MiB |
| SQL long-term backup retention | 12 weekly, 12 monthly, 5 yearly |
| Version covered | 2.45.0 |
What I would tell my past self
- Escrow is a claim, not a fact. Measure it continuously.
- Bind ciphertext to its context. AAD is nearly free and turns a whole class of swap attacks into errors.
- Keep the exposed edge dumb and the smart parts hidden.
- Document your limits. Your future auditors, and your future self, will thank you.
The repository is private, so this series shares the ideas and a few verbatim excerpts rather than the whole codebase. If you take one thing away: the next time a recovery key matters, you want to have already checked.