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.
Two signing surfaces
Section titled “Two signing surfaces”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.
The default is development-only
Section titled “The default is development-only”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.
dotnet add package Granit.Privacy.Vault-
Install one Vault provider — it registers
ITransitMacService:context.Services.AddGranitVaultHashiCorp();// or AddGranitVaultAws() / AddGranitVaultGoogleCloud() / Azure Managed HSM,// or AddGranitSecretBackedMacService(...) for Azure Standard tier. -
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.
Tag format and cutover
Section titled “Tag format and cutover”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
Picking the MAC backend
Section titled “Picking the MAC backend”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.