Key management
Rotating the AEAD master key that protects connector credentials and payloads, plus BYOK/KEK boot unwrapping.
This page covers the master-key / process-level encryption concern: the single AEAD key that protects connector credentials and (optionally) payload bytes, and how to rotate it without downtime. It is distinct from per-tenant BYOK custody — provisioning, rotating, and crypto-shredding a tenant's own KEK — which lives in Key custody.
What the master key protects
Ductor uses XChaCha20-Poly1305 (AEAD) for two purposes:
- connector credentials — always, when a key is configured;
- payload bytes — only when the AEAD payload codec is enabled
(
payload.codec: aead).
Both share one configured master key but derive cryptographically separated
cipher keys via HKDF-SHA256 with purpose-specific labels
(ductor/connector-credentials/v1 and ductor/payload-aead/v1), so the two
planes never reuse the same derived key.
The master key is Tier-0
If the active encryption_key is destroyed and no rotation_keys entry holds
a usable copy, every connector credential and any AEAD-encrypted payload
becomes permanently unrecoverable — the process still starts, but every
Decrypt fails Poly1305 authentication. The encryption is one-way by design;
recovery means re-authenticating every affected tenant. Back the key up
separately, at the same retention class as your database backup.
Why rotation needs no re-encrypt sweep
Wire format v1 carries a key_id byte in every ciphertext. At decrypt time
the runtime picks the matching key from the configured keyring, so a single
deployment can hold the new active key plus any number of prior keys and decode
everything. That makes rotation an additive config change followed by a
rolling restart — there is no offline re-encryption pass and no background
sweep worker (a mis-configured sweep has catastrophic blast radius). New writes
encrypt under the active key_id; old ciphertext keeps decrypting via the
keyring.
Configuration
connector:
encryption_key: "BASE64_OF_ACTIVE_KEY" # base64 of 32 raw bytes
encryption_key_id: 1 # wire key_id byte, 0..255
rotation_keys: # decrypt-only keyring
- key_id: 0
key: "BASE64_OF_PREVIOUS_KEY"Validation is enforced at startup, and any violation fails the process:
encryption_keyis required and must decode to exactly 32 bytes;encryption_key_idmust not appear in anyrotation_keys[*].key_id;- every
rotation_keys[*].key_idis unique and everykeydecodes to 32 bytes.
Generate a fresh key with:
openssl rand 32 | base64Rotation procedure
Never overwrite the active Secret in place
Rotation is additive. Move the current active key into rotation_keys
before promoting the new key — do not blindly replace the connector Secret's
contents. A Helm rollback alone cannot decrypt data written under a new key,
because Kubernetes Secret history is not the keyring. Follow the additive
procedure below.
Generate a new 32-byte key and pick an unused key_id (the id space is
0..255; a typical rotation increments 0 → 1 → 2 …).
Demote the current active key into rotation_keys and promote the
new key to encryption_key / encryption_key_id.
Roll out to a canary pod first; confirm it starts with no
loadConnectorMasterKey / loadConnectorRotationKeys errors and that
existing connections (written under the old key_id) still decrypt. Then
roll the rest.
Let re-encryption happen lazily. Any UpdateConnection, token refresh,
or payload re-write naturally re-encrypts under the new active key — there is
no sweep. To force a single row (e.g. after a known compromise), use the
admin "rotate connection credential" path.
Close the window. Once you're confident no live ciphertext still carries
the old key_id (audit storage first), remove it from rotation_keys.
Cadence: rotate at least every 365 days, or immediately on suspected compromise, an operator with key access leaving, a KMS policy firing, or a compliance requirement. The typical rotation window (how long the old key stays in the keyring) is about 90 days — extend it to cover the longest-lived stored ciphertext you care about.
BYOK / KEK boot dispatch
Instead of holding the raw master key in config, you can wrap it with an
external KEK and have Ductor unwrap it at boot. Set key_provider.*:
key_provider:
enabled: false
# uri: awskms://alias/ductor-master?region=us-east-1
# key_ref: alias/ductor-master
# wrapped_master: "<base64 KMS ciphertext of the 32-byte master>"When key_provider.enabled is true, the uri scheme dispatches to a registered
KEK backend — local:// (development only), awskms://, gcpkms://, or
vault:// — and wrapped_master (base64 KMS ciphertext) is unwrapped into the
runtime master key at boot.
A deployment must configure exactly one real master
key source: connector.encryption_key or key_provider.enabled=true with
wrapped_master. Enterprise deployments must use a real KMS/Vault scheme, not
local://.
Related
Key custody (per-tenant BYOK)
Provisioning, rotating, and crypto-shredding a tenant's own KEK.
Backup & restore
Why the master key is a Tier-0 recovery asset.
The full mechanics — wire envelope layout, cross-version decryption, and the
"I lost the key" recovery path — are in OPERATIONS.md; the on-call
quick-reference is docs/runbooks/connector-payload-key-rotation.md.