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
Concentric shields and a key hierarchy tree with a small grid of mnemonic dots.

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

Stacked defence layers: Azure SQL TDE, envelope encryption, AAD row binding and a dashed recommended layer for Always Encrypted.

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:

The DEK wrapped by a passphrase slot and a recovery-code slot, and the row wire format with AAD.

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

  1. Escrow is a claim, not a fact. Measure it continuously.
  2. Bind ciphertext to its context. AAD is nearly free and turns a whole class of swap attacks into errors.
  3. Keep the exposed edge dumb and the smart parts hidden.
  4. 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.