| # Licensed to the Apache Software Foundation (ASF) under one |
| # or more contributor license agreements. See the NOTICE file |
| # distributed with this work for additional information |
| # regarding copyright ownership. The ASF licenses this file |
| # to you under the Apache License, Version 2.0 (the |
| # "License"); you may not use this file except in compliance |
| # with the License. You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, |
| # software distributed under the License is distributed on an |
| # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| # KIND, either express or implied. See the License for the |
| # specific language governing permissions and limitations |
| # under the License. |
| |
| from unittest import mock |
| from unittest.mock import MagicMock |
| |
| import pytest |
| from sqlalchemy import String |
| from sqlalchemy.engine import make_url |
| from sqlalchemy_utils.types.encrypted.encrypted_type import AesEngine, AesGcmEngine |
| |
| from superset.utils.encrypt import ( |
| EncryptedType, |
| ReEncryptStats, |
| resolve_encryption_engine, |
| SecretsMigrator, |
| SQLAlchemyUtilsAdapter, |
| ) |
| |
| SECRET = {"SECRET_KEY": "x" * 32} |
| SECRET_KEY = "k" * 32 |
| DIALECT = make_url("sqlite://").get_dialect() |
| |
| |
| def _encrypted_type(engine: type) -> EncryptedType: |
| """A standalone EncryptedType for a given engine under SECRET_KEY.""" |
| return EncryptedType(String(1024), key=lambda: SECRET_KEY, engine=engine) |
| |
| |
| def _engine_migrator(target_engine: type) -> SecretsMigrator: |
| """Build a SecretsMigrator in engine-migration mode without an app context. |
| |
| ``__init__`` reads ``current_app`` and the DB dialect, so — like the |
| existing row-level tests that override ``_dialect`` — we set the few |
| attributes ``_re_encrypt_row`` actually uses directly. |
| """ |
| migrator = SecretsMigrator.__new__(SecretsMigrator) |
| migrator._secret_key = SECRET_KEY # noqa: SLF001 |
| migrator._target_engine = target_engine # noqa: SLF001 |
| # Engine migration keeps the SECRET_KEY; previous key defaults to current. |
| migrator._previous_secret_key = SECRET_KEY # noqa: SLF001 |
| migrator._dialect = DIALECT # noqa: SLF001 |
| return migrator |
| |
| |
| def test_default_engine_is_aes_cbc() -> None: |
| """Without config, the adapter keeps the historical AES-CBC engine.""" |
| field = SQLAlchemyUtilsAdapter().create(SECRET, String(128)) |
| assert isinstance(field.engine, AesEngine) |
| |
| |
| def test_aes_gcm_engine_selected_by_config() -> None: |
| """SQLALCHEMY_ENCRYPTED_FIELD_ENGINE='aes-gcm' selects authenticated AES-GCM.""" |
| field = SQLAlchemyUtilsAdapter().create( |
| {**SECRET, "SQLALCHEMY_ENCRYPTED_FIELD_ENGINE": "aes-gcm"}, |
| String(128), |
| ) |
| assert isinstance(field.engine, AesGcmEngine) |
| |
| |
| def test_unknown_engine_raises_fail_closed() -> None: |
| """An unrecognized engine name fails closed at field construction. |
| |
| Silently falling back to unauthenticated AES-CBC would let an operator who |
| typo'd the engine believe they had authenticated encryption — and, after a |
| GCM migration, write new secrets as CBC into a GCM database. The error must |
| not leak the configured value (it shares the config namespace as SECRET_KEY) |
| but must list the valid engines so a typo is diagnosable. |
| """ |
| with pytest.raises( |
| ValueError, match="Unrecognized SQLALCHEMY_ENCRYPTED_FIELD_ENGINE" |
| ) as exc_info: |
| SQLAlchemyUtilsAdapter().create( |
| {**SECRET, "SQLALCHEMY_ENCRYPTED_FIELD_ENGINE": "bogus"}, |
| String(128), |
| ) |
| message = str(exc_info.value) |
| assert "bogus" not in message |
| assert "aes" in message |
| assert "aes-gcm" in message |
| |
| |
| def test_empty_engine_value_raises_fail_closed() -> None: |
| """A present-but-empty engine value fails closed instead of defaulting. |
| |
| Only an *absent* key falls back to AES-CBC. An empty string (e.g. a |
| blanked-out env var) must not silently degrade to unauthenticated CBC after |
| a GCM migration — it routes through the same fail-closed resolver as any |
| other unrecognized value. |
| """ |
| with pytest.raises( |
| ValueError, match="Unrecognized SQLALCHEMY_ENCRYPTED_FIELD_ENGINE" |
| ): |
| SQLAlchemyUtilsAdapter().create( |
| {**SECRET, "SQLALCHEMY_ENCRYPTED_FIELD_ENGINE": ""}, |
| String(128), |
| ) |
| |
| |
| def test_non_string_engine_value_raises_fail_closed() -> None: |
| """A non-string engine value (e.g. ``None``) fails closed, not with an |
| ``AttributeError``. |
| |
| A custom config override could set the engine to a non-string. That must |
| take the same controlled ``ValueError`` path as any unrecognized value |
| rather than raising ``AttributeError`` when the resolver normalizes it. |
| """ |
| with pytest.raises( |
| ValueError, match="Unrecognized SQLALCHEMY_ENCRYPTED_FIELD_ENGINE" |
| ): |
| resolve_encryption_engine(None) |
| |
| |
| def test_engine_name_is_normalized() -> None: |
| """Engine names are case/separator-normalized to match the CLI's Choice.""" |
| for name in ("AES-GCM", "aes_gcm", " Aes-Gcm "): |
| field = SQLAlchemyUtilsAdapter().create( |
| {**SECRET, "SQLALCHEMY_ENCRYPTED_FIELD_ENGINE": name}, |
| String(128), |
| ) |
| assert isinstance(field.engine, AesGcmEngine) |
| |
| |
| def test_explicit_engine_kwarg_takes_precedence() -> None: |
| """An explicit engine kwarg overrides the config (used by the migrator).""" |
| field = SQLAlchemyUtilsAdapter().create( |
| {**SECRET, "SQLALCHEMY_ENCRYPTED_FIELD_ENGINE": "aes-gcm"}, |
| String(128), |
| engine=AesEngine, |
| ) |
| assert isinstance(field.engine, AesEngine) |
| |
| |
| def test_engine_migration_cbc_to_gcm_re_encrypts() -> None: |
| """CBC source value is re-encrypted into GCM under the same SECRET_KEY. |
| |
| Mirrors the recommended runbook: the migrator runs while the config still |
| points at AES-CBC (the column type), re-encrypting into AES-GCM. |
| """ |
| cbc = _encrypted_type(AesEngine) |
| ciphertext = cbc.process_bind_param("hunter2", DIALECT) |
| |
| migrator = _engine_migrator(AesGcmEngine) |
| conn = MagicMock() |
| row = {"id": 1, "password": ciphertext} |
| stats = ReEncryptStats() |
| |
| migrator._re_encrypt_row( # noqa: SLF001 |
| conn, row, "dbs", {"password": cbc}, ["id"], stats |
| ) |
| |
| assert stats == ReEncryptStats(re_encrypted=1) |
| assert conn.execute.call_count == 1 |
| new_value = conn.execute.call_args.kwargs["password"] |
| # The stored value changed and now decrypts as GCM back to the plaintext. |
| assert new_value != ciphertext |
| gcm = _encrypted_type(AesGcmEngine) |
| assert gcm.process_result_value(new_value, DIALECT) == "hunter2" |
| |
| |
| def test_engine_migration_idempotent_for_already_target() -> None: |
| """A value already in the target (GCM) form is skipped — runs are resumable. |
| |
| The column is still configured as CBC (config not yet flipped), but the |
| value has already been migrated to GCM, so it must be left untouched. |
| """ |
| gcm_value = _encrypted_type(AesGcmEngine).process_bind_param("hunter2", DIALECT) |
| cbc_column = _encrypted_type(AesEngine) |
| |
| migrator = _engine_migrator(AesGcmEngine) |
| conn = MagicMock() |
| row = {"id": 1, "password": gcm_value} |
| stats = ReEncryptStats() |
| |
| migrator._re_encrypt_row( # noqa: SLF001 |
| conn, row, "dbs", {"password": cbc_column}, ["id"], stats |
| ) |
| |
| assert stats == ReEncryptStats(skipped=1) |
| assert conn.execute.call_count == 0 |
| |
| |
| def test_engine_migration_reads_cbc_after_config_already_flipped() -> None: |
| """CBC source is still migrated when the config was flipped to GCM first. |
| |
| If an operator sets the config engine to GCM before running the migrator, |
| the column type can no longer read the CBC value; the previous-key (== the |
| current key) AES-CBC decryptor recovers it and it is re-encrypted as GCM. |
| """ |
| cbc_value = _encrypted_type(AesEngine).process_bind_param("hunter2", DIALECT) |
| gcm_column = _encrypted_type(AesGcmEngine) |
| |
| migrator = _engine_migrator(AesGcmEngine) |
| conn = MagicMock() |
| row = {"id": 1, "password": cbc_value} |
| stats = ReEncryptStats() |
| |
| migrator._re_encrypt_row( # noqa: SLF001 |
| conn, row, "dbs", {"password": gcm_column}, ["id"], stats |
| ) |
| |
| assert stats == ReEncryptStats(re_encrypted=1) |
| new_value = conn.execute.call_args.kwargs["password"] |
| assert gcm_column.process_result_value(new_value, DIALECT) == "hunter2" |
| |
| |
| def test_engine_migration_gcm_to_cbc_rolls_back() -> None: |
| """GCM source value is rolled back to CBC under the same SECRET_KEY. |
| |
| The reverse of the forward migration (``--engine aes``). The idempotency |
| fast-path decrypts in the *target* form first; since the target here is |
| unauthenticated AES-CBC, this guards against it mis-reading the AES-GCM |
| ciphertext and wrongly skipping the value instead of re-encrypting it. |
| """ |
| gcm_value = _encrypted_type(AesGcmEngine).process_bind_param("hunter2", DIALECT) |
| gcm_column = _encrypted_type(AesGcmEngine) |
| |
| migrator = _engine_migrator(AesEngine) |
| conn = MagicMock() |
| row = {"id": 1, "password": gcm_value} |
| stats = ReEncryptStats() |
| |
| migrator._re_encrypt_row( # noqa: SLF001 |
| conn, row, "dbs", {"password": gcm_column}, ["id"], stats |
| ) |
| |
| assert stats == ReEncryptStats(re_encrypted=1) |
| new_value = conn.execute.call_args.kwargs["password"] |
| assert new_value != gcm_value |
| # The rolled-back value now decrypts as AES-CBC back to the plaintext. |
| assert _encrypted_type(AesEngine).process_result_value(new_value, DIALECT) == ( |
| "hunter2" |
| ) |
| |
| |
| def test_rollback_authenticated_probe_wins_over_spurious_cbc_skip() -> None: |
| """Rolling back to unauthenticated CBC must re-encrypt a provably-GCM value, |
| never skip it — even if the unauthenticated target decrypt coincidentally |
| succeeds. The authenticated (GCM) interpretation must win. |
| |
| The coincidental CBC-decrypt-of-a-GCM-blob can't be crafted deterministically |
| (it's a ~2^-128 event), so this pins the *ordering invariant* instead: force |
| the target (CBC) read to "succeed", and assert the value is still re-encrypted |
| because the authenticated probe is consulted first and wins. Without the |
| guard this row would be wrongly counted as ``skipped``. |
| """ |
| gcm_value = _encrypted_type(AesGcmEngine).process_bind_param("hunter2", DIALECT) |
| cbc_column = _encrypted_type(AesEngine) |
| |
| migrator = _engine_migrator(AesEngine) # target = unauthenticated CBC (rollback) |
| |
| # Simulate the spurious case: the unauthenticated CBC target read "succeeds" |
| # even though the value is really GCM. |
| spurious_target = MagicMock() |
| spurious_target.engine = AesEngine() |
| spurious_target.underlying_type = cbc_column.underlying_type |
| spurious_target.process_result_value.return_value = "garbage" |
| spurious_target.process_bind_param.return_value = b"new-cbc-ciphertext" |
| |
| conn = MagicMock() |
| row = {"id": 1, "password": gcm_value} |
| stats = ReEncryptStats() |
| |
| with mock.patch.object(migrator, "_target_type", return_value=spurious_target): |
| migrator._re_encrypt_row( # noqa: SLF001 |
| conn, row, "dbs", {"password": cbc_column}, ["id"], stats |
| ) |
| |
| # Re-encrypted, NOT skipped: the GCM authenticator beat the spurious CBC read. |
| assert stats == ReEncryptStats(re_encrypted=1) |
| assert spurious_target.process_bind_param.call_count == 1 |
| |
| |
| def test_combined_key_rotation_and_engine_migration() -> None: |
| """Old-key AES-CBC value → current-key AES-GCM in a single run. |
| |
| Exercises the combined mode (``--previous_secret_key`` + ``--engine``): the |
| source ciphertext is CBC under the *previous* key, and must be recovered and |
| re-encrypted as GCM under the *current* key. This is the mode most likely to |
| regress, since each single-mode test pins only the other's variable. |
| """ |
| old_key = "o" * 32 |
| cbc_old = EncryptedType(String(1024), key=lambda: old_key, engine=AesEngine) |
| old_value = cbc_old.process_bind_param("hunter2", DIALECT) |
| cbc_column = _encrypted_type(AesEngine) |
| |
| migrator = _engine_migrator(AesGcmEngine) |
| migrator._previous_secret_key = old_key # noqa: SLF001 # rotate key too |
| |
| conn = MagicMock() |
| row = {"id": 1, "password": old_value} |
| stats = ReEncryptStats() |
| |
| migrator._re_encrypt_row( # noqa: SLF001 |
| conn, row, "dbs", {"password": cbc_column}, ["id"], stats |
| ) |
| |
| assert stats == ReEncryptStats(re_encrypted=1) |
| new_value = conn.execute.call_args.kwargs["password"] |
| # The migrated value decrypts as GCM under the *current* key. |
| assert _encrypted_type(AesGcmEngine).process_result_value(new_value, DIALECT) == ( |
| "hunter2" |
| ) |
| |
| |
| def _key_rotation_migrator(previous_secret_key: str) -> SecretsMigrator: |
| """Build a SecretsMigrator in key-rotation mode without an app context. |
| |
| Like ``_engine_migrator`` but with no target engine: values are decrypted |
| under ``previous_secret_key`` and re-encrypted under the current key using |
| the column's own engine. |
| """ |
| migrator = SecretsMigrator.__new__(SecretsMigrator) |
| migrator._secret_key = SECRET_KEY # noqa: SLF001 |
| migrator._target_engine = None # noqa: SLF001 |
| migrator._previous_secret_key = previous_secret_key # noqa: SLF001 |
| migrator._dialect = DIALECT # noqa: SLF001 |
| return migrator |
| |
| |
| def test_key_rotation_for_aes_gcm_column() -> None: |
| """SECRET_KEY rotation works for an AES-GCM column. |
| |
| The previous-key fallback must use the column's AES-GCM engine, otherwise |
| GCM ciphertext written under the old key cannot be decrypted and the |
| rotation rolls back. |
| """ |
| old_key = "o" * 32 |
| gcm_old = EncryptedType(String(1024), key=lambda: old_key, engine=AesGcmEngine) |
| old_value = gcm_old.process_bind_param("hunter2", DIALECT) |
| gcm_column = _encrypted_type(AesGcmEngine) |
| |
| migrator = _key_rotation_migrator(previous_secret_key=old_key) |
| conn = MagicMock() |
| row = {"id": 1, "password": old_value} |
| stats = ReEncryptStats() |
| |
| migrator._re_encrypt_row( # noqa: SLF001 |
| conn, row, "dbs", {"password": gcm_column}, ["id"], stats |
| ) |
| |
| assert stats == ReEncryptStats(re_encrypted=1) |
| new_value = conn.execute.call_args.kwargs["password"] |
| assert gcm_column.process_result_value(new_value, DIALECT) == "hunter2" |
| |
| |
| def test_engine_migration_unreadable_value_counts_as_failure() -> None: |
| """A value no engine/key can read is a failure, not a silent pass-through.""" |
| migrator = _engine_migrator(AesGcmEngine) |
| conn = MagicMock() |
| row = {"id": 1, "password": b"not-valid-ciphertext"} |
| stats = ReEncryptStats() |
| |
| migrator._re_encrypt_row( # noqa: SLF001 |
| conn, row, "dbs", {"password": _encrypted_type(AesEngine)}, ["id"], stats |
| ) |
| |
| assert stats == ReEncryptStats(failed=1) |
| assert conn.execute.call_count == 0 |