Data-at-rest encryption
All sensitive values GantryCD persists are encrypted before they hit Postgres:
OIDC client secrets, PKCE code verifiers / OIDC nonces, SCM auth configs, secret
environment variables, and github-actions runner-group signing keys. The seam is
the crypto.SecretCipher interface; encryption is mandatory (boot aborts
without a valid config, after a startup self-test).
Provider model
GANTRYCD_DATA_ENCRYPTION_PROVIDER selects the provider. The value encodes both
the key-wrapping scheme and the content algorithm, so the suite is unambiguous
and crypto-agile:
| Provider | Content | Key source | Required config |
|---|---|---|---|
aes-256 | AES-256-GCM | 32-byte key held in process | GANTRYCD_DATA_ENCRYPTION_AES_256_KEY_B64 (base64 32 bytes) |
aws-kms-aes-256 | AES-256-GCM | per-secret data key (DEK) wrapped by an AWS KMS CMK | GANTRYCD_DATA_ENCRYPTION_AWS_KMS_KEY_ID (ARN/key id/alias); region from GANTRYCD_DATA_ENCRYPTION_AWS_KMS_REGION or AWS_REGION; creds via the default AWS chain |
Provider selection from the environment is backend wiring, not a crypto
primitive: it lives in cmd/backend/data_cipher.go (buildDataCipher), alongside
the other buildXxx initializers, and constructs the AWS/KMS client + runs the
boot self-test. internal/crypto exposes only the cipher constructors. The
loadtest seeder does its own (aes-256-only) wiring in cmd/cli/.../loadtest.
Unified envelope format
Every ciphertext starts with a one-byte scheme identifier; the rest of the
body is owned by that scheme. New providers/algorithms claim a new byte (never
reuse one). crypto.SchemeOf(ciphertext) reports the producing provider from the
bytes alone — useful for audits and future re-encryption tooling.
[ scheme (1 byte) ] [ scheme-specific body ]
0x01 aes-256 body = [nonce(12)] [aes-256-gcm ct+tag]
0x02 aws-kms-aes-256 body = [wrappedDEK len (uvarint)] [wrappedDEK]
[nonce(12)] [aes-256-gcm ct+tag]
internal/crypto/cipher.go holds the SecretCipher interface, the scheme
registry, and SchemeOf/SchemeName. cipher_aes256.go (AES256Cipher) and
cipher_aws_kms_aes256.go (AWSKMSAES256Cipher) are the two providers — one file
per cipher, each with its own cipher_<name>_test.go. Each writes its own scheme
byte and rejects a ciphertext whose leading byte isn’t its own. A process runs a
single configured provider and reads only what that provider can decrypt.
No multi-provider router today: only one provider is active per process. When rotation/re-encryption lands (decrypt old → re-encrypt new), it will need a reader that dispatches by scheme byte — introduce it then, driven by the rotation config, rather than speculatively now.
AAD binding
Repositories pass a stable per-row identifier as aad (a string, e.g.
org_identity_sources/<org>/<source>), so a ciphertext copied to a different row
fails to decrypt. The aad is bound:
- as the AES-GCM additional data on the content (both providers); and
- for
aws-kms-aes-256, additionally as the KMS EncryptionContext of the wrapped DEK — so a row swap fails at DEK-unwrap, before content auth even runs.
aad is a string (not bytes): it is always a readable identifier, which keeps
the KMS EncryptionContext — and thus CloudTrail — legible (the raw identifier,
not a base64 blob).
API shape
SecretCipher is Encrypt(ctx, plaintext []byte, aad string) /
Decrypt(ctx, ciphertext []byte, aad string) — byte-slice payloads, not
io.Reader/io.Writer. AEAD is not natively streamable
(Go’s stdlib cipher.AEAD is one-shot Seal/Open), the payloads are small and
already in memory, and the one-shot contract structurally prevents emitting
unverified plaintext. ctx bounds provider I/O (KMS calls) and carries tracing;
in-process providers ignore it.
Adding a provider (e.g. GCP/Azure KMS, or a new content cipher)
- Allocate the next scheme byte + provider-name constant in
cipher.goand add it toSchemeName. - Add
cipher_<name>.gowith aSecretCipherimplementation that writes/checks its scheme byte; GCP-style providers typically generate the DEK locally withcrypto/randand call the KMS to wrap it (each provider owns its DEK lifecycle). - Wire it into
cmd/backend/data_cipher.go(newDataCipher) under a new provider value. - Add
cipher_<name>_test.gomirroring the existing per-cipher tests (round-trip, AAD binding, tamper, scheme rejection) using an in-memory fake of the KMS API — no live cloud in CI.
Operational notes
- No rotation tool yet. Changing the key/CMK without re-encrypting existing
rows makes them undecryptable. The self-describing scheme byte +
SchemeOfare the foundation for a futurere-encryptjob (decrypt under the old provider, re-encrypt under the new), but it is not implemented. - KMS cost/latency.
aws-kms-aes-256calls KMS on every encrypt and on a decrypt-cache miss. Environment variables group each owner’s secrets into one envelope and fetch all context envelopes together; cold-cache decrypts run with bounded concurrency. The optional Redis cache stores plaintext encrypted under a separate local key and falls back to KMS on any cache failure. - KMS IAM.
aws-kms-aes-256calls onlykms:GenerateDataKey(encrypt path) andkms:Decrypt(decrypt path) on the CMK — grant exactly those, notkms:Encrypt. - Self-test. The backend round-trips a probe at boot (incl. asserting AAD binding holds) so a bad key, missing KMS permission, or wrong region fails fast.