Series · BitLocker Key Monitor: proving recovery keys exist · Part 2 of 3

Azure Functions · Key Vault · Security Architecture · Service Bus

Sending AD scan results securely to Azure

Part 2 of 3. Follow an encrypted scan batch from the domain agent to Azure, including size checks, storage verification, retries and the permissions that keep intake out of decryption.

Drafted
Published
Updated
Reading time
6 min
Author
Roberto Gramellini

Source: BitLocker Key Monitor · revision 6390107e

  • sealed intake
  • envelope encryption
  • AES-256-GCM
  • RSA-OAEP-256
  • Azure Key Vault
  • Azure Functions
  • Service Bus
  • mTLS
  • dead-letter
  • zero trust
A stream of sealed ciphertext blocks crossing a dashed boundary from a server to the cloud.

The scanner in Part 1 needs access to Active Directory, so it runs inside the domain. The reporting service needs the results in Azure. That leaves an uncomfortable delivery job: move sensitive observations out of the domain without handing every component on the route the ability to read them.

The HybridAgent does the LDAP work locally and seals a batch before uploading it. The Azure intake stores that batch without decrypting it. I like this restriction: there is little value in giving an internet-facing receiver access to recovery passwords merely because it handles the post.

Follow the batch, not just the arrows

Eight numbered steps from the hybrid agent through the intake function and Service Bus to the processing function and Azure SQL.

Figure 1. The eight-step route keeps the intake out of plaintext processing.

First the agent reads AD, serialises the dataset and compresses it. It encrypts the archive using AES-256-GCM with a fresh data encryption key, then seals that DEK with a Key Vault public key using RSA-OAEP-256.

The upload travels over HTTPS with mutual TLS. Intake saves the ciphertext unchanged in Blob Storage and queues a small pointer in Service Bus. Only later does the processing function download and verify the blob, ask Key Vault to unwrap the DEK, decrypt the archive and write to Azure SQL. Storage, queuing and reading the contents are deliberately separate jobs.

The agent needs the public key, not an Azure login

Every artifact gets a new 256-bit key. Sealing it does not require the agent to hold the private wrapping key or authenticate directly to Azure:

// src\BitLockerKeyMonitor.Core\Security\DekSealingService.cs (lines 53-64)
var replayNonce = new byte[16];
RandomNumberGenerator.Fill(replayNonce);

var dek = new byte[SealedArchiveCodec.DekSize];
try
{
    RandomNumberGenerator.Fill(dek);

    var (container, payloadNonce) = SealedArchiveCodec.Seal(
        payload, dek, effectiveArtifactId, expiresAt, replayNonce);

    var (sealedDek, keyVersionUri) = await _facade.SealDekAsync(dek, ct).ConfigureAwait(false);

The RSA operation happens on the agent. It fetches the public key material and uses it locally:

// src\BitLockerKeyMonitor.HybridAgent\Security\LocalRsaDekSealingFacade.cs (lines 43-52)
public async Task<(byte[] SealedDek, string KeyVersionUri)> SealDekAsync(byte[] dek, CancellationToken ct)
{
    if (dek is null || dek.Length != 32)
        throw new ArgumentException("DEK must be exactly 32 bytes.", nameof(dek));

    var key = await GetOrRefreshKeyAsync(ct).ConfigureAwait(false);
    using var rsa = RSA.Create();
    rsa.ImportSubjectPublicKeyInfo(key.PublicKeyDer, out _);
    var sealedDek = rsa.Encrypt(dek, RSAEncryptionPadding.OaepSHA256);
    return (sealedDek, key.KeyVersionUri);
}

Keep an eye on keyVersionUri. Returning the specific key version lets processing find the key needed for an in-flight batch after rotation. "Use the latest key" would be a rather optimistic decryption strategy.

Intake accepts bytes and refuses oversized requests

There is no dataset deserialisation in the internet-facing function. It enforces a request bound, hashes the bytes it actually received, stores those bytes and publishes their pointer. The size check covers both a declared content length and bounded reading:

// src\BitLockerKeyMonitor.Intake.Functions\Triggers\SealedBatchIntakeFunction.cs (lines 75-96)
var maxRequestBytes = int.TryParse(
    Environment.GetEnvironmentVariable("BLKMON__HYBRID_INTAKE_MAX_REQUEST_BYTES"),
    out var configuredLimit)
    ? configuredLimit
    : DefaultMaxRequestBytes;
if (request.ContentLength > maxRequestBytes)
{
    return new ObjectResult($"Request exceeds the {maxRequestBytes}-byte intake limit.")
    {
        StatusCode = StatusCodes.Status413PayloadTooLarge,
    };
}

byte[] body;
try
{
    body = await ReadBoundedAsync(request.Body, maxRequestBytes, ct).ConfigureAwait(false);
}
catch (InvalidDataException ex)
{
    return new ObjectResult(ex.Message) { StatusCode = StatusCodes.Status413PayloadTooLarge };
}
// same file (lines 98-107)
// Hash the EXACT bytes about to be stored — this is the container hash the Azure
// processing worker will re-verify before ever attempting to decrypt.
var actualHash = Convert.ToHexString(SHA256.HashData(body));
if (!string.Equals(actualHash, expectedHash, StringComparison.OrdinalIgnoreCase))
{
    return new BadRequestObjectResult("X-BlkMon-Content-Sha256 does not match the request body.");
}

var blobName = SealedArtifactBlobLayout.BuildBlobName(sessionId, artifactId);
var etag = await _blobStore.StoreAsync(blobName, body, actualHash, ct).ConfigureAwait(false);

The intake limit is 24 MiB. Service Bus carries the blob name, hash, length, ETag and key version, not the dataset. Under the intended access separation, compromising intake does not give the attacker a key for opening the ciphertext it receives. That is a confidentiality property, not a promise that a compromised receiver cannot disrupt delivery.

Reject damaged storage before decryption

Processing checks the downloaded blob's length, SHA-256 and ETag before passing it to the decryption handler. These storage checks come before the authenticated-decryption work, rather than replacing it:

// src\BitLockerKeyMonitor.Functions\Triggers\SealedBatchConsumerFunction.cs (lines 111-145)
byte[] container;
try
{
    // Verify-before-decrypt: length, SHA-256, and ETag are all checked inside
    // DownloadVerifiedAsync BEFORE it returns — a corrupted or substituted blob never
    // reaches SealedBatchHandler.
    container = await _blobStore.DownloadVerifiedAsync(
        reference.BlobName,
        reference.ContentSha256,
        reference.ContentLength,
        reference.ETag,
        cancellationToken).ConfigureAwait(false);
}
catch (InvalidDataException ex)
{
    _logger.LogError(
        ex,
        "Sealed artifact {ArtifactId} failed integrity verification (blob={BlobName}); dead-lettering",
        reference.ArtifactId,
        reference.BlobName);
    await actions.DeadLetterMessageAsync(
        message,
        deadLetterReason: "IntegrityFailure",
        deadLetterErrorDescription: ex.Message,
        cancellationToken: cancellationToken).ConfigureAwait(false);
    return;
}

An integrity failure is treated as permanent and sent to the dead-letter queue. Repeating a read of the same damaged artifact will not repair it. Unexpected exceptions take another route: abandon the message so it can be delivered again.

// same file (lines 256-263)
catch (Exception ex)
{
    _logger.LogError(
        ex,
        "Unhandled exception processing sealed artifact {ArtifactId} (messageId={MessageId}); abandoning for redelivery",
        artifactId, message.MessageId);
    await actions.AbandonMessageAsync(message, cancellationToken: cancellationToken).ConfigureAwait(false);
}

That distinction is worth keeping in the runbook. A temporary processing problem needs another attempt; an integrity failure needs investigation. The recorded-batch table handles idempotency, so redelivery of an already recorded batch becomes a no-op rather than a second import.

Permissions have to match the design

Encryption is not enough if the receiver can also ask for decryption. Azure RBAC is therefore part of the design, with assignments scoped to the key rather than the whole vault:

// infra\modules\keyvault.bicep (lines 236-255)
resource sealKeyReadRa 'Microsoft.Authorization/roleAssignments@2022-04-01' = [for pid in sealKeyReadPrincipalIds: {
  scope: agentSealKey
  name: guid(agentSealKey.id, pid, kvCryptoUserRoleId, 'read')
  properties: {
    roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', kvCryptoUserRoleId)
    principalId: pid
    principalType: 'ServicePrincipal'
  }
}]

resource sealKeyDecryptRa 'Microsoft.Authorization/roleAssignments@2022-04-01' = [for pid in sealKeyDecryptPrincipalIds: {
  scope: agentSealKey
  name: guid(agentSealKey.id, pid, kvCryptoUserRoleId, 'decrypt')
  properties: {
    roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', kvCryptoUserRoleId)
    principalId: pid
    principalType: 'ServicePrincipal'
  }
}]

The intended split uses two identity lists: intake serves the public key to agents, while processing can unwrap the DEK with the private key retained in Key Vault. Read the role definitions as well as the list names when reviewing the deployment; labels alone do not establish effective permissions.

A matrix showing that the agent can only read AD data, the intake can only store blobs, and only processing can unwrap and write SQL.

Figure 2. Intended access by component. The agent still reads sensitive AD data locally; sealing limits exposure along the upload route.

What to preserve when deploying it

  • Give the agent its HTTPS and mutual-TLS upload route, not direct access to Key Vault, Storage or SQL.
  • Keep the intake to storing bytes and queuing pointers. The design depends on it having no decryption capability.
  • Keep Azure plaintext handling in the processor, behind private endpoints. The agent necessarily handles the original AD observations before sealing.

Bicep provisions the Azure resources, and managed identities avoid stored Azure credentials. I'd review those effective identities alongside the message failure paths before calling the route ready. Then there is the database: in Part 3, we look at stored-key encryption and the protection it still cannot provide.