Secrets & Key Rotation

A legal-tech platform accumulates secrets quickly: per-tenant encryption keys, vault data-encryption keys, model-provider API tokens, OAuth client secrets, signing keys for audit logs, HMAC keys for pseudonymization. Each has a different blast radius when it leaks and a different cost to rotate. A coherent rotation policy is the difference between "rotated every quarter" as theater and "rotated every quarter" as a verified, automated, audited operation.



1. Envelope Encryption

Every payload is encrypted under a freshly generated data encryption key (DEK), and the DEK is itself encrypted (wrapped) under a long-lived key encryption key (KEK, the "CMK" in AWS / "key" in Azure Key Vault). Three consequences follow:


2. Per-Matter CMKs

For high-sensitivity matters, issue a dedicated KEK per matter. Benefits:


3. Rotation Without Re-Encrypting Payloads

With envelope encryption, rotation is a rewrap, not a re-encryption:

  1. KMS creates a new KEK version; the old version stays active for decryption.
  2. A background job walks every stored wrapped-DEK, unwraps with the old version, rewraps with the new version, writes the new wrapped value.
  3. Once all DEKs are rewrapped, the old KEK version is scheduled for retirement (disabled first, destroyed after a defined retention period).

The payload ciphertext is never touched; only the small wrapped-key blobs move. A tenant with 10 TB of encrypted documents still rotates in minutes.


4. Example: Rewrap Job

import boto3

kms = boto3.client("kms")


def rewrap(wrapped_dek: bytes, target_kek: str,
           encryption_context: dict) -> bytes:
    """Decrypt under the current KEK version, re-encrypt under target_kek."""
    dec = kms.decrypt(
        CiphertextBlob=wrapped_dek,
        EncryptionContext=encryption_context,
    )
    enc = kms.encrypt(
        KeyId=target_kek,
        Plaintext=dec["Plaintext"],
        EncryptionContext=encryption_context,
    )
    return enc["CiphertextBlob"]


def rotate_matter(matter_id: str, new_kek_arn: str, store, audit) -> None:
    audit.log("kek.rotate.start", matter=matter_id, target=new_kek_arn)
    rewrapped = 0
    for row in store.iter_vault_rows(matter_id):
        ctx = {"matter": matter_id, "token": row.token}
        row.wrapped_key = rewrap(row.wrapped_key, new_kek_arn, ctx)
        store.update(row)
        rewrapped += 1
    audit.log("kek.rotate.done", matter=matter_id, target=new_kek_arn,
              rewrapped=rewrapped)


# Encryption context binds the wrap to its purpose: a wrapped-DEK for matter A
# cannot be rewrapped / decrypted under matter B's context, even by an operator
# with permissions on both.

5. Application Secrets & Short-Lived Creds


6. Emergency Rotation (Compromise)

Routine rotation is not a substitute for emergency rotation. When a secret is compromised:

  1. Revoke immediately — disable the key/token at the control plane; this should be a one-command operation with a pre-tested runbook.
  2. Issue replacement — new KEK version, new API token, new signing key as applicable.
  3. Re-wrap or re-issue downstream material — DEKs wrapped under the compromised KEK must be rewrapped under the new one before the old is destroyed.
  4. Audit scope of exposure — correlate the compromised window with KMS decrypt logs, API usage, and access patterns; this is the input to the breach-notification decision.
  5. Write the retro — how did the secret leave the trust boundary, and what change closes the path.

↑ Back to Top