Files
superset2/docs/sip/authenticated-encryption-at-rest.md
2026-06-15 08:10:08 -07:00

6.3 KiB
Raw Blame History

SIP: Authenticated encryption (AES-GCM) for app-encrypted fields

[DRAFT — proposal for discussion]

This document is a draft proposal accompanying the code in this PR. It is intended to seed the formal SIP discussion. The code here ships the backward-compatible engine selection and the re-encryption migrator (Phases 12 below); both are opt-in and change nothing for existing installs by default. Flipping the default for fresh installs (Phase 3) remains future work.

Motivation

Superset app-encrypts a number of sensitive fields before persisting them to the metadata database, including:

  • database connection passwords and encrypted_extra (superset/models/core.py),
  • SSH tunnel credentials — password, private key, private-key password (superset/databases/ssh_tunnel/models.py),
  • OAuth2 tokens and other secrets stored via EncryptedType.

These fields are encrypted with sqlalchemy_utils.EncryptedType, which defaults to AesEngine (AES-CBC). AES-CBC provides confidentiality but is unauthenticated: it has no integrity tag. An attacker with write access to the ciphertext (e.g. direct metadata-DB access, a backup, or a compromised replica) can perform bit-flipping / chosen-ciphertext manipulation to silently alter the decrypted plaintext of a secret without detection.

AesGcmEngine (AES-GCM) is authenticated encryption: tampering causes decryption to fail loudly rather than yielding attacker-influenced plaintext. Using authenticated encryption for secrets at rest is an ASVS L1 expectation (11.3.2 / cryptography best practice).

config.py already documents that operators can switch to GCM by writing a custom AbstractEncryptedFieldAdapter, but:

  1. it is opt-in, undocumented as a security recommendation, and easy to miss;
  2. there is no migration path — flipping the engine on a populated database makes every existing secret undecryptable, because GCM ciphertext is not format-compatible with CBC.

Proposed change

A three-part change, delivered incrementally so existing deployments are never broken:

Phase 1 — engine selection (this PR)

  • Add a SQLALCHEMY_ENCRYPTED_FIELD_ENGINE config ("aes" | "aes-gcm"), defaulting to "aes" (no behavior change for existing installs).
  • Teach the default SQLAlchemyUtilsAdapter to honor it (an explicit engine kwarg still wins, so the migrator can pin an engine).
  • This lets new deployments choose AES-GCM from day one with a one-line config, instead of writing a custom adapter.

Phase 2 — CBC→GCM re-encryption migrator (this PR)

The existing SecretsMigrator (previously only used for SECRET_KEY rotation) gains an engine migration mode that:

  1. discovers every EncryptedType column (via discover_encrypted_fields()),
  2. decrypts each value with the source engine (AES-CBC) under the current SECRET_KEY,
  3. re-encrypts with the target engine (AES-GCM),
  4. runs transactionally per the existing all-or-nothing semantics, and is idempotent per column (already-migrated values are skipped), so a run can be safely repeated or resumed.

Exposed via a new --engine option on the existing CLI command: superset re-encrypt-secrets --engine aes-gcm, runnable by operators with a DB backup in hand. The SECRET_KEY is unchanged; an engine change and a key rotation can also be combined (pass --previous_secret_key as well).

Phase 3 — flip the default for new installs

Once the migrator and docs are in place, change the default to "aes-gcm" for fresh installs only (e.g. keyed off an empty metadata DB / documented in UPDATING.md), keeping existing installs on "aes" until they run Phase 2.

New or changed public interfaces

  • New config: SQLALCHEMY_ENCRYPTED_FIELD_ENGINE: Literal["aes", "aes-gcm"].
  • New (Phase 2) CLI: superset re-encrypt-secrets --engine <name>.
  • No schema changes; ciphertext format changes per migrated column.

Migration plan and compatibility

  • Backward compatible by default. Phase 1 changes nothing unless the operator opts in.
  • Switching an existing deployment to "aes-gcm" without running the Phase 2 migrator will make existing secrets undecryptable — this is called out in the config comment and must be in UPDATING.md.
  • Recommended operator runbook: take a metadata-DB backup → run re-encrypt-secrets --engine aes-gcm → set SQLALCHEMY_ENCRYPTED_FIELD_ENGINE = "aes-gcm" → restart → re-run re-encrypt-secrets --engine aes-gcm once more to sweep up any secrets a live instance wrote as AES-CBC during the cutover window. The canonical, more detailed version of this runbook lives in UPDATING.md; this is a summary.
  • AesEngine allows queryability over encrypted fields; AES-GCM does not. Any code that filters/queries on an encrypted column directly must be audited before Phase 3 (none is expected, but it must be verified).

Rejected alternatives

  • Flip the default immediately. Rejected: bricks every existing deployment's secrets with no migration path.
  • Document-only (custom adapter). Status quo; high friction and no migration tooling — most operators will never do it.

Open questions

  • GCM→CBC rollback (for operators who need queryability) already works via the same command (re-encrypt-secrets --engine aes), since the migrator is engine-symmetric. Should rollback be documented as a supported path or discouraged?
  • The migrator already supports a concurrent SECRET_KEY rotation + engine change in a single pass (pass --previous_secret_key alongside --engine). Is that combination worth calling out in the operator docs, or kept advanced?