Skip to content

Modernize Tool Config credential encryption to AES-256-GCM#15058

Open
Maffooch wants to merge 5 commits into
devfrom
tool-config-aes-gcm-encryption
Open

Modernize Tool Config credential encryption to AES-256-GCM#15058
Maffooch wants to merge 5 commits into
devfrom
tool-config-aes-gcm-encryption

Conversation

@Maffooch

@Maffooch Maffooch commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Summary

Modernizes the encryption used for Tool Config credentials (and any other secret routed through dojo_crypto_encrypt/prepare_for_view) from AES-256-OFB to AES-256-GCM, in a fully backward-compatible way, and bumps the cryptography stack now that the code is safe for it:

The legacy scheme used cryptography.hazmat.primitives.ciphers.modes.OFB. OFB has been moved to the decrepit module and is scheduled for removal from primitives, so the current usage emits a deprecation warning and would eventually break under the bump above — which is exactly why the code change and the bump land together here.

What changed (dojo/utils.py)

  • New "AES.2" format = AES-256-GCM, written by dojo_crypto_encrypt() as AES.2:<nonce_hex>:<ct_hex> (12-byte random nonce, authenticated, no padding).
  • prepare_for_view() reads both formats — branches on the version prefix; AES.2 → GCM, anything else → the legacy OFB path. Existing DB values keep decrypting unchanged.
  • Same key — reuses the existing get_db_key() value (valid 32-byte AES-256 key for both schemes), so no key rotation or settings change.
  • Lazy upgradeAES.1 values transparently re-encrypt to AES.2 the next time a Tool Config is saved (decrypt-on-GET + encrypt-on-save). No data migration.
  • Legacy path future-proofed — OFB is now imported from cryptography.hazmat.decrepit.ciphers.modes (with an ImportError fallback to primitives), silencing the deprecation warning and surviving the eventual removal.
  • Hardened error handling — hex decoding moved inside the try; catches InvalidTag/ValueError/IndexError so a tampered or malformed secret returns "" instead of raising.

The four callers (tool_config/views.py, api_sonarqube/api_client.py, display_tags.py get_pwd) go through these helpers and need no changes. DefectDojo does not import pyopenssl directly — it is a transitive/pinned dependency, so its bump carries no code impact.

Testing

New unit tests in unittests/test_utils.py cover: AES.2 round-trips (ascii/unicode/long/empty), backward-compatible decryption of a legacy AES.1 value, the new AES.2: format prefix, and tampered/garbage input returning "".

Verified the edited helpers against cryptography 49.0.0 with DeprecationWarning promoted to errors: AES.2 round-trips, legacy AES.1 decrypts with no deprecation warnings, and tamper/garbage/None all degrade to "".

🤖 Generated with Claude Code

@Maffooch Maffooch requested a review from mtesauro as a code owner June 22, 2026 18:49
The legacy credential encryption used AES-256-OFB ("AES.1" stored format).
OFB has been moved to cryptography.hazmat.decrepit.ciphers.modes and is
scheduled for removal from primitives.ciphers.modes, which raises a
deprecation warning today and will break the decrypt path in a future
cryptography release.

Introduce a modern "AES.2" scheme using AES-256-GCM (authenticated
encryption, no padding) written via dojo_crypto_encrypt(). prepare_for_view()
now reads both formats, so every secret already stored in the database keeps
decrypting; any unrecognized prefix falls through to the legacy path. The new
scheme reuses the existing get_db_key() value, so no key rotation or settings
change is required. Existing "AES.1" values upgrade lazily the next time a
Tool Config is saved.

The retained legacy decrypt path now imports OFB from its decrepit home (with
an ImportError fallback for older cryptography), silencing the deprecation
warning and surviving the eventual removal. Error handling in
prepare_for_view() is hardened to catch InvalidTag/ValueError/IndexError so a
tampered or malformed value degrades to "" instead of raising.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Maffooch Maffooch force-pushed the tool-config-aes-gcm-encryption branch from 8069794 to c25ddc7 Compare June 22, 2026 18:49
Now safe to land alongside the AES-256-GCM migration: the legacy decrypt path
no longer triggers the OFB deprecation removed/relocated in cryptography 49,
and DefectDojo does not import pyopenssl directly (it is a transitive/pinned
dependency). pyopenssl 26.3.0 requires cryptography >= 49.

Supersedes the standalone Dependabot bumps #15029 and #15032.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Maffooch Maffooch added this to the 3.2.0 milestone Jun 22, 2026
@valentijnscholten

Copy link
Copy Markdown
Member

No migration? At some point we'll have to I think?

@Maffooch

Copy link
Copy Markdown
Contributor Author

Sure I can add a migration. The prepare_for_view reads both formats, and dojo_crypto_encrypt writes with the new algo. A migration felt a little extra to me, but happy to add one

Migration 0270 eagerly upgrades every stored Tool_Configuration credential
(password, ssh, api_key) from the legacy "AES.1" (AES-256-OFB) scheme to the
modern "AES.2" (AES-256-GCM) scheme, rather than waiting for the lazy
per-save upgrade. It re-encrypts only "AES.1"-prefixed values via
prepare_for_view()/dojo_crypto_encrypt(), reuses the existing get_db_key()
(no key rotation), pages in bounded chunks, skips values that fail to
decrypt instead of clobbering them, and is idempotent and reverse-safe.

Also adds REMOVAL TRACKING notes in dojo/utils.py spelling out the
conditions under which the legacy OFB path can be deleted once no "AES.1"
values remain, with bidirectional pointers between the migration and utils.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added the New Migration Adding a new migration file. Take care when merging. label Jun 22, 2026
@valentijnscholten

Copy link
Copy Markdown
Member

As far as I can see it only reencrypts whenever the user changes or saves the value. Which may never happen for many entries.

@mtesauro mtesauro left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved

@valentijnscholten valentijnscholten left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest to add something to the upgrade notes to make users aware of this in case they run into problems or are crypto fanatics.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@valentijnscholten valentijnscholten left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. I think in the future when the old algorithms are deleted, we'll have a break of backwards compatibility where users first need to upgrade to the latest version that still can re-encrypt old values.

@github-actions github-actions Bot added the docs label Jun 23, 2026
updates[field] = dojo_crypto_encrypt(decrypted)

if updates:
Tool_Configuration.objects.filter(id=row["id"]).update(**updates)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a risk here that we'd overflow some of these fields, which will cause the migration to crash. The new encryption scheme includes additional data (auth tag, 16 bytes) that could prevent some encrypted fields that are at the max length from being upgraded.

To replicate, under the dev branch create a tool config with the following password:
password:92WF889R18WRQP3XVPPHSPTCWRCXFVY7VCWPA4QIY7NUJN9GYSZ4DJZ13VLW35TGYRYJTJ1XQFLUERAUQ28PUAT3XGMRNRV4U4L81HZXCPIK7BCAEIU4TU8J17GV2HZBDE67KUR1CY52WDIPTRNQ1P9PG4GRJK2MIJZDUPR60ONIZA6RPOLYPKY8UU3YJFRBJPCVUK7ZFJTT6W87ON9WJHBFD1LJI9G7DF4F1UFUJXEF235NCJ1QYH022L1ND0BYKE1TFP

Save it. (Well -- you'll have to go back and edit it and save it. It looks like encryption doesn't happen on create? Is that a side issue?). Confirm the password in the DB is prefixed with "AES.1."

Then switch to the new branch and run the migration. It will fail with an exception like django.db.utils.DataError: value too long for type character varying(600).

Probably the simplest solution would be to just add an extra ~50 chars to the appropriate model fields.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! Will update the migration to expand the field size as well

GCM appends a 12-byte nonce and 16-byte auth tag, so values stored at
the old column max length under AES.1 would overflow when the migration
re-encrypts them. Grow password/ssh/api_key by 50% (600->900, 6000->9000,
600->900) via AlterField in the existing migration, run before the
re-encryption so the larger values fit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs New Migration Adding a new migration file. Take care when merging. unittests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants