Skip to content

feat(security): encryption key versioning + AAD binding for EncryptedJSONField (#87)#171

Open
b3lz3but wants to merge 2 commits intocaptainpragmatic:masterfrom
b3lz3but:feat/encryption-key-versioning-aad
Open

feat(security): encryption key versioning + AAD binding for EncryptedJSONField (#87)#171
b3lz3but wants to merge 2 commits intocaptainpragmatic:masterfrom
b3lz3but:feat/encryption-key-versioning-aad

Conversation

@b3lz3but
Copy link
Copy Markdown
Contributor

@b3lz3but b3lz3but commented Apr 7, 2026

Summary

Closes #87 — implements the Better tier (key versioning + AAD context binding) for the AES-256-GCM encryption layer.

1. Key versioning with keyring fallback

  • Wire format: aes:v1:<payload> (no AAD) / aes:v2:<payload> (AAD-bound)
  • ENCRYPTION_KEYS setting: ordered list [current, ...previous]
  • Decryption tries all keys in the keyring — zero-downtime key rotation
  • Legacy aes:<payload> format (pre-versioning) still decrypts
  • Prod: DJANGO_ENCRYPTION_KEY_PREVIOUS env var for comma-separated old keys
  • All existing ~40 callsites produce v1 format (no aad= parameter) — zero breaking changes

2. AAD (Associated Authenticated Data) context binding

  • EncryptedJSONField encrypts with aad=b"table:field:pk" via thread-local from pre_save
  • v2 wire format embeds AAD in payload: aes:v2:<base64url(aad_len[2B] + aad + nonce[12B] + ciphertext + tag[16B])>
  • Cross-table ciphertext transplant detected via AAD prefix check in from_db_value
  • GCM authentication fails if embedded AAD is tampered (byte-level integrity)

3. Data migration command

  • python manage.py reencrypt_with_aad — re-encrypt v1 → v2 with AAD context
  • --dry-run flag to preview, --batch for batch size, idempotent (skips v2)
  • Iterates all models with EncryptedJSONField (currently CustomerPaymentMethod.bank_details)

Test plan

  • 62 tests pass (34 existing + 13 key versioning/AAD + 4 field AAD + 11 backup codes)
  • Legacy aes:<payload> format still decrypts (backward compat)
  • Keyring fallback: data encrypted with old key decrypts when old key is in ENCRYPTION_KEYS
  • AAD tampering: flipping a byte in embedded AAD fails GCM authentication
  • v2 format verified: new EncryptedJSONField saves produce aes:v2: wire format
  • AAD context includes table name, field name, and pk
  • Pre-existing test failures on master confirmed unrelated (customer model tests)
  • Ruff lint + format + MyPy type checks pass
  • All 16 pre-commit hooks pass
  • DCO signed

Closes #87

🤖 Generated with Claude Code

b3lz3but and others added 2 commits April 7, 2026 16:19
…JSONField (captainpragmatic#87)

Close two security gaps in the AES-256-GCM encryption layer:

1. Key versioning with keyring fallback:
   - New wire format: aes:v1:<payload> (no AAD) / aes:v2:<payload> (AAD-bound)
   - ENCRYPTION_KEYS setting: ordered list [current, ...previous]
   - Decryption tries all keys in keyring — enables zero-downtime key rotation
   - Legacy "aes:<payload>" format (pre-versioning) still decrypts
   - Prod: DJANGO_ENCRYPTION_KEY_PREVIOUS env var for comma-separated old keys

2. AAD (Associated Authenticated Data) context binding:
   - EncryptedJSONField encrypts with aad=b"table:field:pk"
   - v2 wire format embeds AAD in payload for self-contained verification
   - Cross-table ciphertext transplant detected via AAD prefix check
   - GCM authentication fails if embedded AAD is tampered

3. Management command for data migration:
   - reencrypt_with_aad: re-encrypt v1 → v2 with AAD context
   - --dry-run flag, batch processing, idempotent

Backward compatible: all existing callsites (40+) use no aad= parameter
and produce v1 format. AAD is opt-in via EncryptedJSONField only.

Closes captainpragmatic#87

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Ciprian Radulescu <craps2003@gmail.com>
…patibility

encrypt_sensitive_data() was calling get_encryption_keys()[0] directly,
bypassing the get_encryption_key() wrapper. This broke existing test
patches that mock get_encryption_key (e.g. test_mfa_with_encryption_key_missing).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Ciprian Radulescu <craps2003@gmail.com>
@b3lz3but
Copy link
Copy Markdown
Contributor Author

@mostlyvirtual ready for review 🙏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Encryption key versioning + AAD binding for EncryptedJSONField

1 participant