| title | Envelope encryption — local KEK and AWS KMS | |||
|---|---|---|---|---|
| description | Wrap chunks with AES-256-GCM, anchored either to a local KEK file or an AWS KMS key. | |||
| tags |
|
Walks through both legs of envelope encryption: a local-KEK setup (one file on disk, no cloud) and an AWS KMS setup that wraps the per-backup DEK with a customer-managed key. About 15 minutes if you have AWS credentials handy; the local-KEK leg works offline.
pg_hardstorage encrypts every chunk with a per-backup AES-256-GCM
DEK. The DEK is wrapped by a Key Encryption Key (KEK) and the wrapped
copy lands in the manifest. There are two KEK schemes shipped today:
local:default— a 32-byte file at<keyring>/kek.bin. Default when the file exists. Good for tutorials, single-host deployments, and air-gapped sites.aws-kms://<key-id>— every wrap and unwrap callsKMS:Encrypt/KMS:Decryptagainst the configured key. Good for fleets that already centralise key custody.
GCP KMS (gcp-kms://), Azure Key Vault (azure-key-vault://), and
HashiCorp Vault Transit (vault-transit://) ship as well, and
kms shred (crypto-shred) is available today. PKCS#11 / TPM2 (HSM)
binding ships in the pkcs11 / FIPS build flavour.
!!! danger "Back up your keys — you cannot restore without them"
With the local local:default scheme, kek.bin in the keyring
directory unwraps every backup. Lose the keyring directory and
your encrypted backups are mathematically unrecoverable — the
same effect kms shred produces on purpose. Find the resolved path
with pg_hardstorage doctor, override it with the
PG_HARDSTORAGE_KEYRING_DIR environment variable, and back the
keyring directory up separately from the repository, with at least
the care you'd give a database password. The Ed25519 signing keypair
lives in the same directory. For production, prefer a cloud KMS
scheme so key custody isn't a single local file.
- A reachable PostgreSQL 15+ instance and
pg_hardstoragev0.2 or later (see getting-started). - 200 MB free disk for the sandbox repo.
# RUNNABLE
pg_hardstorage repo init file:///tmp/hs-encrypt-repoThe init wizard generates a signing keypair and a kek.bin on
first run. The default is encryption on:
# RUNNABLE
pg_hardstorage init \
--pg-connection "${PG_CONNECTION:-postgres://postgres:postgres@127.0.0.1/postgres}" \
--repo file:///tmp/hs-encrypt-repo \
--deployment db1 \
--skip-backup \
--yesInspect the keystore (no secret bytes are printed):
# RUNNABLE
pg_hardstorage kms inspectKeyring: /home/you/.config/pg_hardstorage/keyring
signing-public.pem ed25519 fp=ab12...ef34
signing-private.pem 0600
kek.bin 0600 · 32 bytesIf you already have a keyring without a KEK, generate one with:
head -c 32 /dev/urandom > ~/.config/pg_hardstorage/keyring/kek.bin
chmod 0600 ~/.config/pg_hardstorage/keyring/kek.binbackup chooses encryption automatically when a KEK is present:
# RUNNABLE
pg_hardstorage backup db1 \
--pg-connection "${PG_CONNECTION:-postgres://postgres:postgres@127.0.0.1/postgres}" \
--repo file:///tmp/hs-encrypt-repoForce encryption (refuse if no KEK present) with --encrypt. Force
plaintext (escape hatch for mixed-posture fleets) with --no-encrypt.
# RUNNABLE
pg_hardstorage show db1 latest \
--repo file:///tmp/hs-encrypt-repo \
-o json | jq '.encryption'{
"kek_ref": "local:default",
"wrapped_dek_b64": "...",
"envelope_version": 1
}The chunk bodies on disk are AES-GCM ciphertext keyed by a fresh
DEK; without the KEK, neither the manifest's wrapped_dek nor the
chunks tell you anything.
# RUNNABLE
pg_hardstorage kms verify --repo file:///tmp/hs-encrypt-repokms verify walks every manifest and exercises the UnwrapDEK path
without restoring. A failure here means a KEK mismatch or
manifest corruption — fix it before you find out at restore time.
# RUNNABLE skip-in-ci="postverify pg_ctl-start needs continuous WAL in the repo; this tutorial does not run `wal stream`, so recovery hangs reading trailing segments. The restore + decrypt path is exercised end-to-end by the restore-correctness CI matrix."
pg_hardstorage restore db1 latest \
--repo file:///tmp/hs-encrypt-repo \
--target /tmp/hs-encrypt-restoredThe restore reads the KEKRef from the manifest, opens the matching provider, unwraps the DEK, and decrypts chunks in-process. Nothing changes from the operator's point of view; that is the whole goal of envelope design.
- AWS credentials in scope — environment variables, IMDS, or
~/.aws/credentialsprofile. The role needskms:Encrypt,kms:Decrypt, andkms:DescribeKeyon the chosen key. - A customer-managed key in the same region as the operator host.
Aliases (
alias/pg-hardstorage-prod) and full ARNs both work; do not use AWS-managed keys (you cannot rotate them on your schedule).
If you do not already have one:
aws kms create-key --description "pg_hardstorage demo KEK" \
--key-spec SYMMETRIC_DEFAULT --key-usage ENCRYPT_DECRYPT
aws kms create-alias --alias-name alias/pg-hardstorage-demo \
--target-key-id <key-id-from-above>Pass --kek with the aws-kms:// scheme. Region (and any FIPS or
endpoint override) goes through --kms-config:
pg_hardstorage backup db1 \
--pg-connection "${PG_CONNECTION:-postgres://postgres:postgres@127.0.0.1/postgres}" \
--repo file:///tmp/hs-encrypt-repo \
--kek aws-kms://alias/pg-hardstorage-demo \
--kms-config region=eu-central-1The runner opens the matching kms.Provider, asks it to wrap the
per-backup DEK, and stamps the KEKRef into the manifest. Each chunk
PUT carries the x-amz-checksum-sha256 of the plaintext — the
backend rejects mismatches.
A FIPS-mode endpoint:
--kms-config region=us-east-1,use_fips_endpoint=truepg_hardstorage show db1 latest \
--repo file:///tmp/hs-encrypt-repo \
-o json | jq '.encryption'{
"kek_ref": "aws-kms://arn:aws:kms:eu-central-1:123456789012:key/abc-...",
"wrapped_dek_b64": "...",
"envelope_version": 1
}The wrapped DEK is unwrappable only by an IAM principal with
kms:Decrypt on that key. A copy of the repo on a different account,
or in a region without that key, is plaintext-equivalent worthless.
pg_hardstorage restore db1 latest \
--repo file:///tmp/hs-encrypt-repo \
--target /tmp/hs-encrypt-restoredNo extra flags. The restore reads the KEKRef, opens the matching
provider, and asks AWS KMS to unwrap. If the calling principal lacks
kms:Decrypt, you get kms.unauthorized (exit 8) with the suggested
IAM statement to add.
pg_hardstorage kms verify --repo file:///tmp/hs-encrypt-repoSame command as the local-KEK leg. The verifier walks every manifest and exercises the unwrap path; AWS KMS calls are sequential and rate- limited, so a large repo takes longer than the local-KEK case.
Two operations are wired in v0.2 but locked behind n-of-m approval:
pg_hardstorage kms shred --confirm-keyring <keyring-dir> --require-approval ...
pg_hardstorage kms rotate --repo "$REPO" \
--old-kek-ref local:default --old-kek-file old-kek.bin \
--new-kek-ref aws-kms://alias/pg-hardstorage-demo-2026 --new-kek-file new-kek.bin --applykms shreddestroys the local KEK irreversibly. Every backup wrapped under that KEK is then plaintext-unrecoverable (this is the point — GDPR right-to-erasure). Refuses without an approval token.kms rotatere-wraps every encrypted manifest's DEK with a new KEK, in place. The old KEK can then be retired.
Land neither in production until you have read R2 — KMS key destroyed.
You exercised both halves of envelope encryption. Local KEK gave you
a hermetic, single-file setup; AWS KMS handed key custody to your
cloud control plane while the wire format and operator commands
stayed identical. The manifest's kek_ref is the only thing that
changes between the two.
The design rule: the repo is encrypted at rest by virtue of its own contents — not by virtue of where it is stored. A leaked S3 bucket, a copied disk, or a rsync to a third party leaks ciphertext only.
- LLM incident walkthrough — using the helper to triage a failed restore (often a missing KMS permission).
- R2 — KMS key destroyed — recovery (or not) when the KEK is gone.
- Operator guide — Encryption — day-2 KMS operations.