Operations

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

configs/ductor.yaml
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_key is required and must decode to exactly 32 bytes;
  • encryption_key_id must not appear in any rotation_keys[*].key_id;
  • every rotation_keys[*].key_id is unique and every key decodes to 32 bytes.

Generate a fresh key with:

openssl rand 32 | base64

Rotation 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.*:

configs/ductor.yaml
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.

wrapped_master+ key_provider.uri uri scheme awskms / gcpkms vault local (dev only) unwrap at boot runtime master key

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://.

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.