# Key management (/docs/operations/key-management)



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](/docs/auth/key-custody).

## What the master key protects [#what-the-master-key-protects]

Ductor uses &#x2A;*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.

<Callout type="error" title="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.
</Callout>

## Why rotation needs no re-encrypt sweep [#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 [#configuration]

```yaml title="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:

```bash
openssl rand 32 | base64
```

## Rotation procedure [#rotation-procedure]

<Callout type="warn" title="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.
</Callout>

<Steps>
  <Step>
    **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 …`).
  </Step>

  <Step>
    **Demote** the current active key into `rotation_keys` and **promote** the
    new key to `encryption_key` / `encryption_key_id`.
  </Step>

  <Step>
    **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.
  </Step>

  <Step>
    **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.
  </Step>

  <Step>
    **Close the window.** Once you're confident no live ciphertext still carries
    the old `key_id` (audit storage first), remove it from `rotation_keys`.
  </Step>
</Steps>

**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 [#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.*`:

```yaml title="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.

```mermaid
flowchart LR
    Config["wrapped_master<br/>+ key_provider.uri"] --> Scheme{"uri scheme"}
    Scheme --> KMS["awskms / gcpkms"]
    Scheme --> Vault["vault"]
    Scheme --> Local["local (dev only)"]
    KMS --> Unwrap["unwrap at boot"]
    Vault --> Unwrap
    Local --> Unwrap
    Unwrap --> Master["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://`.

## Related [#related]

<Cards>
  <Card title="Key custody (per-tenant BYOK)" href="/docs/auth/key-custody">
    Provisioning, rotating, and crypto-shredding a tenant's own KEK.
  </Card>

  <Card title="Backup & restore" href="/docs/operations/backup-restore">
    Why the master key is a Tier-0 recovery asset.
  </Card>
</Cards>

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`.
