Skip to content
GantryCD

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:

ProviderContentKey sourceRequired config
aes-256AES-256-GCM32-byte key held in processGANTRYCD_DATA_ENCRYPTION_AES_256_KEY_B64 (base64 32 bytes)
aws-kms-aes-256AES-256-GCMper-secret data key (DEK) wrapped by an AWS KMS CMKGANTRYCD_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)

  1. Allocate the next scheme byte + provider-name constant in cipher.go and add it to SchemeName.
  2. Add cipher_<name>.go with a SecretCipher implementation that writes/checks its scheme byte; GCP-style providers typically generate the DEK locally with crypto/rand and call the KMS to wrap it (each provider owns its DEK lifecycle).
  3. Wire it into cmd/backend/data_cipher.go (newDataCipher) under a new provider value.
  4. Add cipher_<name>_test.go mirroring 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 + SchemeOf are the foundation for a future re-encrypt job (decrypt under the old provider, re-encrypt under the new), but it is not implemented.
  • KMS cost/latency. aws-kms-aes-256 calls 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-256 calls only kms:GenerateDataKey (encrypt path) and kms:Decrypt (decrypt path) on the CMK — grant exactly those, not kms: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.