Skip to content

Signing personal-data exports

Every fragment and manifest in the data-export pipeline carries an HMAC integrity tag. The assembler verifies that tag before it opens the source blob, so a buggy provider — or an attacker — cannot make the archive reference a blob the subject does not own, and a downloaded manifest can be proven untampered. This page covers the signer behind those tags and how to take it to production.

The pipeline signs two different things, so there are two interfaces — both backed by the same key material but kept separate to prevent a fragment tag from ever validating against manifest bytes (or vice versa).

| Interface | Signs | Used by | | --------- | ----- | ------- | | IExportHmacSigner | Fragment identity tuples (request id, subject, provider, kind, source blob, entry path, expiry) | Every provider, when a fragment is produced | | IExportContentSigner | Arbitrary byte payloads — the manifest sidecar | The assembly job, when sealing the manifest |

public interface IExportHmacSigner
{
string Sign(in ExportHmacParameters parameters);
bool Verify(in ExportHmacParameters parameters, string tag);
}
public interface IExportContentSigner
{
string SignBytes(ReadOnlySpan<byte> payload);
bool VerifyBytes(ReadOnlySpan<byte> payload, string tag);
}

ExportHmacParameters is canonicalized in declaration order before signing, and fragment tags bind an ExpiresAt so a captured tag cannot be replayed after the export window closes.

Granit.Privacy.BlobStorage registers EphemeralExportHmacSigner as the default implementation of both interfaces. It generates a fresh 256-bit key on every process start (logged as a warning). That is fine for a single local process and useless for anything else: a shard signed by one replica fails verification in the next, and a restart invalidates every in-flight export.

To stop that reaching production, Granit.Privacy.BlobStorage ships a fail-fast startup guard:

Granit.Privacy.Vault — the production signer

Section titled “Granit.Privacy.Vault — the production signer”

Granit.Privacy.Vault provides VaultExportHmacSigner, which implements both interfaces by delegating to whichever ITransitMacService the host registered. The key lives in the vault, survives restarts, and is shared across replicas — so the startup guard accepts the boot.

Terminal window
dotnet add package Granit.Privacy.Vault
  1. Install one Vault provider — it registers ITransitMacService:

    context.Services.AddGranitVaultHashiCorp();
    // or AddGranitVaultAws() / AddGranitVaultGoogleCloud() / Azure Managed HSM,
    // or AddGranitSecretBackedMacService(...) for Azure Standard tier.
  2. Wire the export signer with two distinct key names:

    context.Services.AddGranitPrivacyVaultExportSigner(o =>
    {
    o.FragmentKeyName = "granit-privacy-export-fragment-mac";
    o.ContentKeyName = "granit-privacy-export-content-mac";
    });

    Or load the module, which calls the same extension:

    [DependsOn(typeof(GranitPrivacyVaultModule))]
    public class MyAppModule : GranitModule { }

The extension uses services.Replace (not TryAdd) because the BlobStorage package always registers the ephemeral signer — call it after AddGranitPrivacyBlobStorage() so the vault-backed signer wins.

VaultExportHmacSigner wraps the provider’s opaque tag in a framework prefix:

gpv1:{providerOpaqueTag}

On verify, the gpv1: prefix is stripped before the remainder is handed to the underlying ITransitMacService. This makes the cutover explicit:

sequenceDiagram
    participant Asm as Assembler
    participant Signer as VaultExportHmacSigner
    participant Mac as ITransitMacService

    Note over Asm,Mac: Sign
    Asm->>Signer: Sign(parameters)
    Signer->>Mac: MacAsync(FragmentKeyName, canonical bytes)
    Mac-->>Signer: opaque tag
    Signer-->>Asm: "gpv1:" + opaque tag

    Note over Asm,Mac: Verify
    Asm->>Signer: Verify(parameters, tag)
    alt tag starts with "gpv1:"
        Signer->>Mac: VerifyAsync(FragmentKeyName, bytes, tag without prefix)
        Mac-->>Signer: valid / invalid
    else legacy "vN:base64" tag
        Signer-->>Asm: false (rejected — no auto-migration)
    end

Granit.Privacy.Vault depends only on the Granit.Vault abstractions — the actual MAC backend is whichever provider module the host installs:

| Host platform | Register | MAC primitive | | ------------- | -------- | ------------- | | HashiCorp Vault | AddGranitVaultHashiCorp() | Transit HMAC | | AWS | AddGranitVaultAws() | KMS GenerateMac / VerifyMac | | GCP | AddGranitVaultGoogleCloud() | Cloud KMS MacSign / MacVerify | | Azure (Managed HSM, premium) | AddGranitVaultAzureManagedHsmMac() | HS256 on oct-HSM | | Azure (Standard tier) / portable | AddGranitSecretBackedMacService(...) | local HMAC over a pulled key |

See Vault-backed MAC for the provider matrix, rotation window semantics, and the trust-boundary trade-off of the portable fallback.