From eae9c14f60a3c0f15f2381b455e171b12bee7508 Mon Sep 17 00:00:00 2001 From: Cody Maffucci <46459665+Maffooch@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:11:49 -0600 Subject: [PATCH] Revert "Modernize Tool Config credential encryption to AES-256-GCM (#15058)" This reverts commit 7f27bd98b42fcbffcfe232a914f465566481a839. --- docs/content/releases/os_upgrading/3.2.md | 22 ---- ...encrypt_tool_config_credentials_aes_gcm.py | 113 ------------------ dojo/tool_config/models.py | 6 +- dojo/utils.py | 73 +++-------- requirements.txt | 4 +- unittests/test_utils.py | 41 +------ 6 files changed, 23 insertions(+), 236 deletions(-) delete mode 100644 docs/content/releases/os_upgrading/3.2.md delete mode 100644 dojo/db_migrations/0270_reencrypt_tool_config_credentials_aes_gcm.py diff --git a/docs/content/releases/os_upgrading/3.2.md b/docs/content/releases/os_upgrading/3.2.md deleted file mode 100644 index 46df4ea70ac..00000000000 --- a/docs/content/releases/os_upgrading/3.2.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -title: 'Upgrading to DefectDojo Version 3.2.x' -toc_hide: true -weight: -20260623 -description: Tool Configuration credentials are re-encrypted to AES-256-GCM. ---- - -## Tool Configuration credentials upgraded to AES-256-GCM - -DefectDojo encrypts the credentials stored on Tool Configurations (the `password`, `ssh`, and `api_key` fields). Previously these values were encrypted with AES-256 in OFB mode (the `AES.1` stored format). This release introduces a modern `AES.2` format that uses AES-256-GCM, an authenticated encryption scheme that detects tampering with the stored ciphertext. - -New and updated credentials are written in the `AES.2` format automatically. The encryption key is unchanged — both formats derive their key from the same `DD_CREDENTIAL_AES_256_KEY`, so no key rotation or settings change is required. - -A data migration (`0270_reencrypt_tool_config_credentials_aes_gcm`) included in this release eagerly re-encrypts every existing `AES.1` credential to `AES.2` on upgrade. The legacy `AES.1` decryption path is retained for backward compatibility, so any value that has not yet been migrated continues to decrypt normally. The same migration also widens the `password`, `ssh`, and `api_key` columns by 50% so that credentials stored at the old maximum length still fit once the GCM nonce and authentication tag are added. - -This release also bumps `cryptography` to 49.0.0 and `pyopenssl` to 26.3.0. - -### What you need to do - -Nothing — the change is applied automatically by the database migration included in this release. Ensure your `DD_CREDENTIAL_AES_256_KEY` is unchanged from your prior deployment so the existing credentials can be decrypted and re-encrypted; a value that fails to decrypt (for example, because it was encrypted under a different key) is left untouched rather than overwritten. - -For more information, check the [Release Notes](https://github.com/DefectDojo/django-DefectDojo/releases/tag/3.2.0). diff --git a/dojo/db_migrations/0270_reencrypt_tool_config_credentials_aes_gcm.py b/dojo/db_migrations/0270_reencrypt_tool_config_credentials_aes_gcm.py deleted file mode 100644 index 78ea9ecc04f..00000000000 --- a/dojo/db_migrations/0270_reencrypt_tool_config_credentials_aes_gcm.py +++ /dev/null @@ -1,113 +0,0 @@ -import logging - -from django.db import migrations, models - -logger = logging.getLogger(__name__) - -# Tool_Configuration fields that hold credentials encrypted via -# dojo_crypto_encrypt()/prepare_for_view(). Each is re-encrypted from the legacy -# "AES.1" (AES-256-OFB) scheme to the modern "AES.2" (AES-256-GCM) scheme. -ENCRYPTED_FIELDS = ("password", "ssh", "api_key") - -# Legacy stored-format prefix written by the old prepare_for_save(). -LEGACY_PREFIX = "AES.1:" - -# Re-encrypt in bounded chunks so a large Tool_Configuration table never loads -# every row into memory at once. -BATCH_SIZE = 500 - - -def reencrypt_tool_config_credentials(apps, schema_editor): - """ - Eagerly upgrade every stored Tool_Configuration credential from the legacy - "AES.1" (AES-256-OFB) format to the modern "AES.2" (AES-256-GCM) format. - - prepare_for_view() already reads both formats, so values would otherwise - upgrade only lazily the next time a Tool Config is saved. This migration - performs the transition proactively so the legacy OFB decrypt path can - eventually be removed once no "AES.1" values remain. See the "REMOVAL - TRACKING (legacy OFB path)" note in dojo/utils.py for the conditions under - which that legacy code (encrypt/decrypt/prepare_for_save and the OFB import) - can be deleted. - - Both schemes reuse the same key from get_db_key(); no key rotation or - settings change is involved. A value that fails to decrypt (e.g. produced - with a different key) is left untouched rather than clobbered. - """ - # Imported here, not at module load, so the migration graph can be built - # without pulling in the full dojo.utils runtime/settings dependencies. - from dojo.utils import dojo_crypto_encrypt, prepare_for_view - - Tool_Configuration = apps.get_model("dojo", "Tool_Configuration") - - upgraded = 0 - last_id = 0 - while True: - page = list( - Tool_Configuration.objects.filter(id__gt=last_id) - .order_by("id") - .values("id", *ENCRYPTED_FIELDS)[:BATCH_SIZE], - ) - if not page: - break - last_id = page[-1]["id"] - - for row in page: - updates = {} - for field in ENCRYPTED_FIELDS: - value = row[field] - if not value or not value.startswith(LEGACY_PREFIX): - continue - decrypted = prepare_for_view(value) - if not decrypted: - # Decryption failed (wrong key / tampered value). Leave the - # stored value as-is instead of overwriting it with junk. - logger.warning( - "Skipping Tool_Configuration %s field %r: legacy value did not decrypt", - row["id"], field, - ) - continue - updates[field] = dojo_crypto_encrypt(decrypted) - - if updates: - Tool_Configuration.objects.filter(id=row["id"]).update(**updates) - upgraded += 1 - - if upgraded: - logger.info("Re-encrypted credentials for %d Tool_Configuration rows to AES-256-GCM", upgraded) - - -def noop_reverse(apps, schema_editor): - # The "AES.2" values remain readable by prepare_for_view(); there is no need - # (and no benefit) to downgrade them back to the legacy OFB scheme. - pass - - -class Migration(migrations.Migration): - dependencies = [ - ("dojo", "0269_normalize_blank_finding_components"), - ] - - operations = [ - # Widen the encrypted credential columns first. AES-256-GCM appends a - # 12-byte nonce and a 16-byte authentication tag (rendered as hex in the - # "AES.2::" payload), so a value stored at the old max length - # under AES.1 would overflow the column when re-encrypted below. Each - # field is grown by 50% of its previous max_length to leave ample room. - migrations.AlterField( - model_name="tool_configuration", - name="password", - field=models.CharField(blank=True, max_length=900, null=True), - ), - migrations.AlterField( - model_name="tool_configuration", - name="ssh", - field=models.CharField(blank=True, max_length=9000, null=True), - ), - migrations.AlterField( - model_name="tool_configuration", - name="api_key", - field=models.CharField(blank=True, max_length=900, null=True, verbose_name="API Key"), - ), - migrations.RunPython(reencrypt_tool_config_credentials, noop_reverse), - ] diff --git a/dojo/tool_config/models.py b/dojo/tool_config/models.py index f8281c098db..6190fe839ce 100644 --- a/dojo/tool_config/models.py +++ b/dojo/tool_config/models.py @@ -17,11 +17,11 @@ class Tool_Configuration(models.Model): extras = models.CharField(max_length=255, null=True, blank=True, help_text=_("Additional definitions that will be " "consumed by scanner")) username = models.CharField(max_length=200, null=True, blank=True) - password = models.CharField(max_length=900, null=True, blank=True) + password = models.CharField(max_length=600, null=True, blank=True) auth_title = models.CharField(max_length=200, null=True, blank=True, verbose_name=_("Title for SSH/API Key")) - ssh = models.CharField(max_length=9000, null=True, blank=True) - api_key = models.CharField(max_length=900, null=True, blank=True, + ssh = models.CharField(max_length=6000, null=True, blank=True) + api_key = models.CharField(max_length=600, null=True, blank=True, verbose_name=_("API Key")) class Meta: diff --git a/dojo/utils.py b/dojo/utils.py index 3e5bd882fd5..cb45de77eaf 100644 --- a/dojo/utils.py +++ b/dojo/utils.py @@ -23,18 +23,8 @@ import redis as redis_lib import vobject from amqp.exceptions import ChannelError -from cryptography.exceptions import InvalidTag from cryptography.hazmat.backends import default_backend -from cryptography.hazmat.primitives.ciphers import Cipher, algorithms -from cryptography.hazmat.primitives.ciphers.aead import AESGCM - -# OFB powers the legacy "AES.1" decryption path only. It has been moved to the -# "decrepit" module and is being removed from primitives.ciphers.modes; import -# it from its new home when available, falling back for older cryptography. -try: - from cryptography.hazmat.decrepit.ciphers.modes import OFB -except ImportError: # cryptography that predates the decrepit modes module - from cryptography.hazmat.primitives.ciphers.modes import OFB +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cvss import CVSS2, CVSS3, CVSS4 from dateutil.parser import parse from dateutil.relativedelta import MO, SU, relativedelta @@ -970,31 +960,11 @@ def reopen_external_issue(finding_id, note, external_issue_provider, **kwargs): from dojo.notifications.helper import process_tag_notifications # noqa: E402, F401 -- backward compat -# --------------------------------------------------------------------------- -# Legacy "AES.1" credential encryption: AES-256-OFB with null-byte padding. -# Retained for backward-compatible decryption of values already stored in the -# database. New values are written with the "AES.2" (AES-256-GCM) scheme below -# via dojo_crypto_encrypt(); existing "AES.1" values upgrade lazily the next -# time they are saved. -# -# REMOVAL TRACKING (legacy OFB path): -# Migration 0270_reencrypt_tool_config_credentials_aes_gcm eagerly re-encrypts -# every stored Tool_Configuration credential to "AES.2", so after it has run in -# every environment there should be no "AES.1" values left in the database. -# Once that migration is squashed/baked into the release floor (i.e. no upgrade -# path can skip it) and any external integrations have been confirmed not to -# persist their own "AES.1" values, the entire legacy path can be deleted: -# - encrypt() / decrypt() / _pad_string() / _unpad_string() below -# - the OFB import at the top of this module -# - the "AES.1" else-branch in prepare_for_view() -# - prepare_for_save() (only ever produced the "AES.1" format) -# Do NOT remove any of the above until all stored secrets have been re-encrypted. -# --------------------------------------------------------------------------- def encrypt(key, iv, plaintext): text = "" if plaintext and plaintext is not None: backend = default_backend() - cipher = Cipher(algorithms.AES(key), OFB(iv), backend=backend) + cipher = Cipher(algorithms.AES(key), modes.OFB(iv), backend=backend) encryptor = cipher.encryptor() plaintext = _pad_string(plaintext) encrypted_text = encryptor.update(plaintext) + encryptor.finalize() @@ -1004,7 +974,7 @@ def encrypt(key, iv, plaintext): def decrypt(key, iv, encrypted_text): backend = default_backend() - cipher = Cipher(algorithms.AES(key), OFB(iv), backend=backend) + cipher = Cipher(algorithms.AES(key), modes.OFB(iv), backend=backend) encrypted_text_bytes = binascii.a2b_hex(encrypted_text) decryptor = cipher.decryptor() decrypted_text = decryptor.update(encrypted_text_bytes) + decryptor.finalize() @@ -1024,18 +994,14 @@ def _unpad_string(value): def dojo_crypto_encrypt(plaintext): - # New values are encrypted with the modern "AES.2" (AES-256-GCM) scheme. - # AESGCM provides authenticated encryption (no separate padding needed) and - # uses the same key derived by get_db_key(), so it stays interoperable with - # the legacy "AES.1" decryption path. See prepare_for_view() for reads. data = None if plaintext: + key = None key = get_db_key() - # GCM standard nonce length is 96 bits (12 bytes); never reuse a nonce - # with the same key, hence a fresh random nonce per encryption. - nonce = os.urandom(12) - ciphertext = AESGCM(key).encrypt(nonce, plaintext.encode("utf-8"), None) - data = "AES.2:" + binascii.b2a_hex(nonce).decode("utf-8") + ":" + binascii.b2a_hex(ciphertext).decode("utf-8") + + iv = os.urandom(16) + data = prepare_for_save( + iv, encrypt(key, iv, plaintext.encode("utf-8"))) return data @@ -1060,10 +1026,7 @@ def get_db_key(): def prepare_for_view(encrypted_value): - # Reads both the modern "AES.2" (AES-256-GCM) format written by - # dojo_crypto_encrypt() and the legacy "AES.1" (AES-256-OFB) format. Any - # unrecognized prefix falls through to the legacy path so that values - # already stored in the database continue to decrypt unchanged. + key = None decrypted_value = "" if encrypted_value is not NotImplementedError and encrypted_value is not None: @@ -1071,19 +1034,13 @@ def prepare_for_view(encrypted_value): encrypted_values = encrypted_value.split(":") if len(encrypted_values) > 1: - scheme = encrypted_values[0] + iv = binascii.a2b_hex(encrypted_values[1]) + value = encrypted_values[2] + try: - iv = binascii.a2b_hex(encrypted_values[1]) - value = encrypted_values[2] - if scheme == "AES.2": - decrypted_value = AESGCM(key).decrypt(iv, binascii.a2b_hex(value), None).decode("utf-8") - else: - # Legacy "AES.1" (AES-256-OFB) read path. Removable once - # migration 0270 is guaranteed to have run everywhere and no - # "AES.1" values remain -- see the REMOVAL TRACKING note on - # the encrypt()/decrypt() block above. - decrypted_value = decrypt(key, iv, value).decode("utf-8") - except (UnicodeDecodeError, InvalidTag, ValueError, IndexError): + decrypted_value = decrypt(key, iv, value) + decrypted_value = decrypted_value.decode("utf-8") + except UnicodeDecodeError: decrypted_value = "" return decrypted_value diff --git a/requirements.txt b/requirements.txt index ac5c98fea4c..44e593ee9e3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -32,7 +32,7 @@ Markdown==3.10.2 openpyxl==3.1.5 Pillow==12.2.0 # required by django-imagekit psycopg[c]==3.3.4 -cryptography==49.0.0 +cryptography==46.0.7 python-dateutil==2.9.0.post0 redis==8.0.1 requests==2.34.2 @@ -65,6 +65,6 @@ netaddr==1.3.0 vulners==3.1.11 fontawesomefree==6.6.0 PyYAML==6.0.3 -pyopenssl==26.3.0 +pyopenssl==26.2.0 parameterized==0.9.0 setuptools==82.0.1 diff --git a/unittests/test_utils.py b/unittests/test_utils.py index 7206653426a..6d28e57dede 100644 --- a/unittests/test_utils.py +++ b/unittests/test_utils.py @@ -1,6 +1,4 @@ -import binascii import logging -import os from contextlib import contextmanager from unittest.mock import Mock, patch @@ -23,7 +21,7 @@ Test_Import_Finding_Action, ) from dojo.notifications.signals import create_default_notifications -from dojo.utils import dojo_crypto_encrypt, encrypt, get_db_key, prepare_for_save, prepare_for_view +from dojo.utils import dojo_crypto_encrypt, prepare_for_view from .dojo_test_case import DojoTestCase @@ -51,43 +49,10 @@ class TestUtils(DojoTestCase): def test_encryption(self): test_input = "Hello World!" - encrypted = dojo_crypto_encrypt(test_input) - test_output = prepare_for_view(encrypted) + encrypt = dojo_crypto_encrypt(test_input) + test_output = prepare_for_view(encrypt) self.assertEqual(test_input, test_output) - def test_encryption_uses_aes2_format(self): - # New values must be written with the modern AES-256-GCM ("AES.2") scheme. - encrypted = dojo_crypto_encrypt("some secret") - self.assertTrue(encrypted.startswith("AES.2:")) - - def test_encryption_roundtrip_variants(self): - # GCM has no block-size constraint, so cover empty, unicode, and long - # (multi-block) inputs to be sure padding-free encryption round-trips. - for value in ["", "ascii-secret", "ünïcödé-pä$$wörd", "x" * 500]: - with self.subTest(value=value): - self.assertEqual(value, prepare_for_view(dojo_crypto_encrypt(value))) - - def test_decrypt_legacy_aes1_value(self): - # Values stored by the legacy AES-256-OFB ("AES.1") scheme must still - # decrypt unchanged so existing database secrets are never stranded. - plaintext = "legacy-secret" - key = get_db_key() - iv = os.urandom(16) - legacy_value = prepare_for_save(iv, encrypt(key, iv, plaintext.encode("utf-8"))) - self.assertTrue(legacy_value.startswith("AES.1:")) - self.assertEqual(plaintext, prepare_for_view(legacy_value)) - - def test_decrypt_tampered_or_garbage_returns_empty(self): - # A tampered AES.2 ciphertext (auth tag mismatch) and unparseable input - # must degrade to "" rather than raising. - encrypted = dojo_crypto_encrypt("tamper-me") - scheme, nonce_hex, ct_hex = encrypted.split(":") - ct = bytearray(binascii.a2b_hex(ct_hex)) - ct[0] ^= 0xFF # flip a byte to break the GCM auth tag - tampered = ":".join([scheme, nonce_hex, binascii.b2a_hex(bytes(ct)).decode("utf-8")]) - self.assertEqual("", prepare_for_view(tampered)) - self.assertEqual("", prepare_for_view("AES.2:zzzz:zzzz")) - @patch("dojo.notifications.signals.Notifications") def test_create_default_notifications_without_template(self, mock_notifications): user = Dojo_User()