CWE-326: Inadequate Encryption Strength - Python
Overview
Inadequate Encryption Strength in Python applications occurs when developers use weak cryptographic algorithms, insufficient key sizes, or deprecated ciphers that can be broken by modern computing power. Python offers several cryptographic libraries (PyCryptodome, cryptography, hashlib), and each leaves the choice of algorithm and key size to the caller.
Common Python Vulnerability Scenarios:
- Using DES or 3DES instead of AES
- Using MD5 or SHA-1 for security-critical operations
- Applying weak RSA key sizes (1024 bits, roughly 80-bit strength)
- Using ECB mode which doesn't provide semantic security
- Relying on deprecated or custom cryptographic implementations
Python Cryptographic Landscape:
- cryptography: Modern library with safe defaults (recommended)
- PyCryptodome: Drop-in replacement for PyCrypto with updated algorithms
- hashlib: Built-in library for hashing (use SHA-256 or stronger)
- secrets: Built-in module for cryptographically secure random generation
Framework-Specific Considerations:
- Django: Uses PBKDF2-SHA256 by default; configure
PASSWORD_HASHERSfor stronger options - Flask: No built-in encryption; use Flask-Bcrypt or cryptography library
- FastAPI: Use
pwdlibwith Argon2, which is what FastAPI's own security tutorial now recommends. Do not reach forpasslib: its last release was 1.7.4 in October 2020, and its bcrypt backend readsbcrypt.__about__, which was removed in bcrypt 4.1.0 - a logged warning on bcrypt 4.x and a real failure on 5.0.0, which raises on passwords over 72 bytes instead of truncating them.passlibis still the practical choice for reading legacy hash formats during a migration, and nothing more
Primary Defence: Use the cryptography library for symmetric encryption - Fernet for a safe-by-default recipe at the 128-bit level (it is AES-128-CBC with an HMAC-SHA256 integrity check, encrypt-then-MAC), or AES-256-GCM directly where a 256-bit key is required. Use bcrypt or argon2 for password hashing, and avoid deprecated algorithms like DES or MD5.
Common Vulnerable Patterns
Using DES Encryption
from Crypto.Cipher import DES
import base64
class UserDataEncryption:
def __init__(self, key):
# DES uses 56-bit effective key strength - easily broken
self.key = key[:8].ljust(8, b'\0') # DES requires 8-byte key
def encrypt_ssn(self, ssn):
cipher = DES.new(self.key, DES.MODE_ECB)
# ECB mode is also vulnerable to pattern analysis
padded = ssn.ljust(16, ' ')
encrypted = cipher.encrypt(padded.encode())
return base64.b64encode(encrypted).decode()
def decrypt_ssn(self, encrypted_ssn):
cipher = DES.new(self.key, DES.MODE_ECB)
decrypted = cipher.decrypt(base64.b64decode(encrypted_ssn))
return decrypted.decode().strip()
# Usage in Flask application
from flask import Flask, request, jsonify
app = Flask(__name__)
encryptor = UserDataEncryption(b'weak_key')
@app.route('/api/users', methods=['POST'])
def create_user():
data = request.json
encrypted_ssn = encryptor.encrypt_ssn(data['ssn'])
# Store encrypted_ssn in database
return jsonify({'status': 'created', 'encrypted_ssn': encrypted_ssn})
Why this is vulnerable:
- DES has only 56-bit effective key strength (can be brute-forced in hours)
- ECB mode reveals patterns in encrypted data
- No authentication or integrity protection
- Key derivation is naive and predictable
Weak RSA Key Size
# VULNERABLE - Weak RSA Key Size
from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_OAEP
import base64
class APIKeyEncryption:
def __init__(self):
# 1024-bit RSA is considered weak by modern standards
self.key = RSA.generate(1024)
self.cipher = PKCS1_OAEP.new(self.key)
def encrypt_api_key(self, api_key):
encrypted = self.cipher.encrypt(api_key.encode())
return base64.b64encode(encrypted).decode()
def decrypt_api_key(self, encrypted_key):
decrypted = self.cipher.decrypt(base64.b64decode(encrypted_key))
return decrypted.decode()
# Django view using weak encryption
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
encryptor = APIKeyEncryption()
@csrf_exempt
def store_api_credentials(request):
if request.method == 'POST':
api_key = request.POST.get('api_key')
encrypted = encryptor.encrypt_api_key(api_key)
# Store encrypted key in database
return JsonResponse({'encrypted_key': encrypted})
Why this is vulnerable:
- 1024-bit RSA can be factored by well-resourced adversaries; it buys roughly 80-bit strength, not 1024 bits of anything.
- NIST disallowed 1024-bit keys for applying protection in 2013.
- Generate 3072-bit keys for new work. RSA-2048 is rated at 112-bit strength and SP 800-57 Part 1 Table 4 accepts it for applying protection only through 2030, so it is a floor with an expiry date rather than a target - which is what the secure pattern below uses 3072 for.
Using MD5 for Password Hashing
import hashlib
from flask import Flask, request, session
from flask_sqlalchemy import SQLAlchemy
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///users.db'
db = SQLAlchemy(app)
class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
password_hash = db.Column(db.String(32), nullable=False)
def hash_password(password):
# MD5 is cryptographically broken and vulnerable to collisions
return hashlib.md5(password.encode()).hexdigest()
@app.route('/register', methods=['POST'])
def register():
username = request.form['username']
password = request.form['password']
# VULNERABLE - MD5 hashing without salt
password_hash = hash_password(password)
user = User(username=username, password_hash=password_hash)
db.session.add(user)
db.session.commit()
return {'status': 'registered'}
@app.route('/login', methods=['POST'])
def login():
username = request.form['username']
password = request.form['password']
user = User.query.filter_by(username=username).first()
if user and user.password_hash == hash_password(password):
session['user_id'] = user.id
return {'status': 'logged_in'}
return {'status': 'failed'}, 401
Why this is vulnerable:
- MD5 has practical collision attacks and is cryptographically broken.
- Fast hashing enables large-scale brute-force attacks.
- No salt means identical passwords share hashes and enable rainbow tables.
AES-CBC with No Authentication and No Key Derivation
# VULNERABLE - unauthenticated CBC, and a key that is never derived or rotated
from Crypto.Cipher import AES
from Crypto.Random import get_random_bytes
from Crypto.Util.Padding import pad, unpad
import base64
class TokenEncryption:
def __init__(self):
# AES-128 is fine as a key size; what follows is not
self.key = get_random_bytes(16)
def encrypt_token(self, token):
cipher = AES.new(self.key, AES.MODE_CBC)
padded = pad(token.encode(), AES.block_size)
encrypted = cipher.encrypt(padded)
# VULNERABLE - CBC produces no tag, so nothing detects a modified token
return base64.b64encode(cipher.iv + encrypted).decode()
def decrypt_token(self, encrypted_token):
raw = base64.b64decode(encrypted_token)
iv = raw[:16]
ciphertext = raw[16:]
cipher = AES.new(self.key, AES.MODE_CBC, iv=iv)
decrypted = unpad(cipher.decrypt(ciphertext), AES.block_size)
return decrypted.decode()
# FastAPI usage
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
app = FastAPI()
token_encryptor = TokenEncryption()
class TokenRequest(BaseModel):
token: str
@app.post("/api/encrypt-token")
async def encrypt_user_token(request: TokenRequest):
encrypted = token_encryptor.encrypt_token(request.token)
return {"encrypted_token": encrypted}
Why this is vulnerable:
- CBC produces no authentication tag, so
decrypt_tokenwill return whatever a modified ciphertext decrypts to. An attacker who can edit the stored token can flip chosen bits of the first plaintext block by flipping the same bits of the IV, which travels in the clear at the front of the value. Unpadding failures that reach the caller as a distinguishable error turn this into a padding oracle and recover the whole plaintext. - The key is generated in
__init__, so it lives only in that process. Every restart, and every additional worker, gets a different key and cannot read tokens issued by the others. - There is no key version in the stored value, so there is no way to rotate.
- The AES-128 key is not the defect, despite what a scanner rule keyed on
get_random_bytes(16)may report. AES-128 carries a full 128-bit security strength; substituting a 256-bit key leaves every point above untouched. Prefer AES-256 for new work as a margin decision, and record an existing AES-128 deployment as a false positive rather than remediating the number.
Using SHA-1 for HMAC
# VULNERABLE - Using SHA-1 for HMAC
import hmac
import hashlib
from flask import Flask, request, jsonify
app = Flask(__name__)
SECRET_KEY = b'my_secret_key'
def generate_signature(data):
# SHA-1 is deprecated for cryptographic purposes
return hmac.new(SECRET_KEY, data.encode(), hashlib.sha1).hexdigest()
def verify_signature(data, signature):
expected = generate_signature(data)
return hmac.compare_digest(expected, signature)
@app.route('/api/webhook', methods=['POST'])
def webhook():
data = request.get_json()
signature = request.headers.get('X-Signature')
message = str(data)
if not verify_signature(message, signature):
return jsonify({'error': 'Invalid signature'}), 403
# Process webhook
return jsonify({'status': 'processed'})
@app.route('/api/sign', methods=['POST'])
def sign_data():
data = request.json.get('data')
signature = generate_signature(data)
return jsonify({'signature': signature})
Why this is vulnerable:
- SHA-1 provides weaker security margins than SHA-256.
- Deprecated by modern standards and security guidance.
- Use SHA-256 or SHA-3 for future-proofing.
Weak Password-Based Encryption
# VULNERABLE - Weak Password-Based Encryption
from Crypto.Protocol.KDF import PBKDF2
from Crypto.Cipher import AES
from Crypto.Random import get_random_bytes
import base64
class PasswordBasedEncryption:
def __init__(self, password):
# Weak iteration count makes brute-force easier
salt = b'fixed_salt_12345' # Fixed salt is also problematic
# Only 1000 iterations (modern standard is 600,000+)
self.key = PBKDF2(password, salt, dkLen=16, count=1000)
def encrypt_data(self, plaintext):
cipher = AES.new(self.key, AES.MODE_CBC)
from Crypto.Util.Padding import pad
encrypted = cipher.encrypt(pad(plaintext.encode(), AES.block_size))
return base64.b64encode(cipher.iv + encrypted).decode()
def decrypt_data(self, encrypted_data):
raw = base64.b64decode(encrypted_data)
iv = raw[:16]
ciphertext = raw[16:]
cipher = AES.new(self.key, AES.MODE_CBC, iv=iv)
from Crypto.Util.Padding import unpad
return unpad(cipher.decrypt(ciphertext), AES.block_size).decode()
# Django view
from django.http import JsonResponse
from django.views import View
class EncryptedStorage(View):
def post(self, request):
password = request.POST.get('password')
data = request.POST.get('data')
encryptor = PasswordBasedEncryption(password)
encrypted = encryptor.encrypt_data(data)
# Store encrypted data
return JsonResponse({'encrypted': encrypted})
Why this is vulnerable:
- 1,000 PBKDF2 iterations are far below OWASP guidance, which is 600,000 for PBKDF2-HMAC-SHA256.
- Fixed salts make identical passwords produce identical keys.
- PyCryptodome's
PBKDF2defaults to HMAC-SHA1 when nohmac_hash_moduleis passed, so this derives with SHA-1 without ever naming it. - The resulting AES-CBC is unauthenticated, so a modified ciphertext decrypts
rather than being rejected.
dkLen=16is not the problem - a 128-bit derived key is sound; raising it to 32 changes nothing about the three points above.
Custom Encryption Implementation
import random
from flask import Flask, request
app = Flask(__name__)
class CustomEncryption:
"""Home-grown encryption - NEVER DO THIS"""
def __init__(self, key):
self.key = key
random.seed(key) # Predictable randomness
def encrypt(self, plaintext):
encrypted = []
for char in plaintext:
# XOR with pseudo-random value - weak encryption
shift = random.randint(0, 255)
encrypted.append(chr(ord(char) ^ shift))
return ''.join(encrypted)
def decrypt(self, ciphertext):
random.seed(self.key) # Reset seed
decrypted = []
for char in ciphertext:
shift = random.randint(0, 255)
decrypted.append(chr(ord(char) ^ shift))
return ''.join(decrypted)
encryptor = CustomEncryption(12345)
@app.route('/api/encrypt', methods=['POST'])
def encrypt_data():
data = request.json.get('data')
encrypted = encryptor.encrypt(data)
return {'encrypted': encrypted}
Why this is vulnerable:
- Custom cryptography is almost always broken
- XOR with predictable PRNG is trivially breakable
- No authenticated encryption
- Violates Kerckhoffs's principle (security through obscurity)
Using 3DES Instead of AES
# VULNERABLE - Using 3DES Instead of AES
from Crypto.Cipher import DES3
from Crypto.Random import get_random_bytes
import base64
class LegacyEncryption:
def __init__(self):
# 3DES is deprecated and should not be used for new systems
self.key = get_random_bytes(24) # 168 key bits, ~112-bit strength
def encrypt_credit_card(self, cc_number):
cipher = DES3.new(self.key, DES3.MODE_CBC)
from Crypto.Util.Padding import pad
padded = pad(cc_number.encode(), DES3.block_size)
encrypted = cipher.encrypt(padded)
return base64.b64encode(cipher.iv + encrypted).decode()
def decrypt_credit_card(self, encrypted_cc):
raw = base64.b64decode(encrypted_cc)
iv = raw[:8]
ciphertext = raw[8:]
cipher = DES3.new(self.key, DES3.MODE_CBC, iv=iv)
from Crypto.Util.Padding import unpad
return unpad(cipher.decrypt(ciphertext), DES3.block_size).decode()
# FastAPI application
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
legacy_encryptor = LegacyEncryption()
class PaymentRequest(BaseModel):
credit_card: str
@app.post("/api/process-payment")
async def process_payment(payment: PaymentRequest):
encrypted_cc = legacy_encryptor.encrypt_credit_card(payment.credit_card)
# Process payment with encrypted data
return {"status": "processed", "encrypted_cc": encrypted_cc}
Why this is vulnerable:
- Three-key TDEA is disallowed by NIST for applying protection after 31 December 2023 (SP 800-131A Rev 2); decryption of data already protected with it stays permitted as legacy use, so this is about new ciphertext.
- The 24-byte key is 168 key bits but only about 112 bits of strength, because a meet-in-the-middle attack beats the key search. This is the case where the number in the comment and the security you get are genuinely different, which is not true of AES-128.
- 64-bit block size makes it vulnerable to birthday attacks (Sweet32) once a few tens of gigabytes are encrypted under one key.
- Slower than AES with no security benefit.
Secure Patterns
AES-256-GCM Encryption
# SECURE - AES-256-GCM: authenticated encryption with a fresh nonce per message
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
import base64
import os
def encrypt(plaintext: str, key: bytes) -> str:
nonce = os.urandom(12)
aesgcm = AESGCM(key)
ciphertext = aesgcm.encrypt(nonce, plaintext.encode(), b"CTX_V1")
return base64.b64encode(nonce + ciphertext).decode("utf-8")
def decrypt(token: str, key: bytes) -> str:
raw = base64.b64decode(token)
nonce, ciphertext = raw[:12], raw[12:]
aesgcm = AESGCM(key)
return aesgcm.decrypt(nonce, ciphertext, b"CTX_V1").decode("utf-8")
# Short usage
key = AESGCM.generate_key(bit_length=256)
encrypted = encrypt("123-45-6789", key)
decrypted = decrypt(encrypted, key)
Why this works:
- AES-256 key size resists brute-force.
- GCM provides AEAD (confidentiality + integrity).
- Unique 96-bit nonce prevents reuse.
- AAD binds context to ciphertext.
- Combined nonce + ciphertext keeps storage simple.
RSA-3072 for Asymmetric Encryption
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives import hashes
import base64
# NIST rates 2048-bit at 112-bit strength and accepts it only through 2030;
# 3072-bit gives 128-bit and stays valid beyond that.
private_key = rsa.generate_private_key(public_exponent=65537, key_size=3072)
public_key = private_key.public_key()
def encrypt_api_key(api_key: str) -> str:
ciphertext = public_key.encrypt(
api_key.encode(),
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None,
),
)
return base64.b64encode(ciphertext).decode("utf-8")
def decrypt_api_key(encrypted_key: str) -> str:
ciphertext = base64.b64decode(encrypted_key)
plaintext = private_key.decrypt(
ciphertext,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None,
),
)
return plaintext.decode("utf-8")
# Short usage
encrypted = encrypt_api_key("api-key")
decrypted = decrypt_api_key(encrypted)
Why this works:
- 3072-bit RSA provides 128-bit security strength and stays valid past 2030. NIST SP 800-57 Part 1 Table 4 rates 2048-bit at 112-bit strength: Acceptable for applying protection through 2030 and Disallowed from 2031, while processing data already protected at that strength remains permitted as legacy use. A 2048-bit key generated today therefore has a known expiry, and the rule governs new key material rather than re-encryption of existing data.
- OAEP + SHA-256 blocks padding oracles.
- Explicit params avoid weak defaults.
- Use for small secrets or key wrapping.
Bcrypt for Password Hashing
import bcrypt
def hash_password(password: str) -> str:
return bcrypt.hashpw(
password.encode("utf-8"),
bcrypt.gensalt(rounds=12),
).decode("utf-8")
def verify_password(password: str, stored_hash: str) -> bool:
return bcrypt.checkpw(
password.encode("utf-8"),
stored_hash.encode("utf-8"),
)
# Short usage
hashed = hash_password("Str0ngPassw0rd!")
ok = verify_password("Str0ngPassw0rd!", hashed)
Why this works:
- Adaptive cost factor slows brute-force and scales with hardware.
- Per-password salts block rainbow tables and identical hashes.
- Hash format stores version, cost, salt, and hash together.
checkpwperforms a timing-safe comparison.
Argon2id for Password Hashing
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
ph = PasswordHasher(memory_cost=65536, time_cost=3, parallelism=4)
def hash_password(password: str) -> str:
return ph.hash(password)
def verify_password(password: str, stored_hash: str) -> bool:
try:
return ph.verify(stored_hash, password)
except VerifyMismatchError:
return False
# Short usage
hashed = hash_password("Str0ngPassw0rd!")
ok = verify_password("Str0ngPassw0rd!", hashed)
Why this works:
- Argon2id is the PHC winner and recommended default.
- Memory cost resists GPU/ASIC cracking.
- Time and parallelism are tunable.
- Encoded params support verification and rehashing.
HMAC-SHA256 for Message Authentication
import hmac
import hashlib
import secrets
SECRET_KEY = secrets.token_bytes(32)
def sign(data: str) -> str:
return hmac.new(SECRET_KEY, data.encode(), hashlib.sha256).hexdigest()
def verify(data: str, signature: str) -> bool:
expected = sign(data)
return hmac.compare_digest(expected, signature)
# Short usage
sig = sign("payload")
ok = verify("payload", sig)
Why this works:
- HMAC-SHA256 provides integrity and authenticity.
- Key length >= 32 bytes matches hash strength.
- Constant-time compare prevents timing leaks.
Fernet (Symmetric Encryption with Built-in Best Practices)
from cryptography.fernet import Fernet
# Fernet is AES-128-CBC then HMAC-SHA256 over the result (encrypt-then-MAC).
# generate_key() returns 32 bytes: 16 for the cipher, 16 for the MAC.
key = Fernet.generate_key()
fernet = Fernet(key)
def encrypt(plaintext: str) -> str:
return fernet.encrypt(plaintext.encode()).decode("utf-8")
def decrypt(token: str) -> str:
return fernet.decrypt(token.encode()).decode("utf-8")
# Short usage
token = encrypt("secret")
plaintext = decrypt(token)
Why this works:
- Encrypt-then-MAC detects tampering before decrypt:
decryptverifies the HMAC first and raisesInvalidTokenrather than returning modified plaintext. - Random IVs ensure non-deterministic ciphertexts.
- Token includes a timestamp, which
decrypt(token, ttl=seconds)will enforce. Without thettlargument the timestamp is carried but not checked. - Fernet's 128-bit AES key is not a CWE-326 finding. AES-128 carries a full
128-bit security strength, and the format is fixed, so a scanner rule that
flags Fernet on key length is reporting the specification rather than a
defect. If policy mandates AES-256 throughout, use
AESGCMabove instead of trying to force a longer key into Fernet.
Key Management with AWS Secrets Manager
import boto3
from cryptography.fernet import Fernet
import json
class AWSSecretsEncryption:
"""Encryption with AWS Secrets Manager for key management"""
def __init__(self, region_name: str = "us-east-1"):
self.secrets_client = boto3.client("secretsmanager", region_name=region_name)
def get_encryption_key(self, secret_name: str) -> bytes:
response = self.secrets_client.get_secret_value(SecretId=secret_name)
secret = json.loads(response["SecretString"])
return secret["encryption_key"].encode()
def encrypt_data(self, data: str, secret_name: str) -> str:
key = self.get_encryption_key(secret_name)
return Fernet(key).encrypt(data.encode()).decode("utf-8")
def decrypt_data(self, encrypted_data: str, secret_name: str) -> str:
key = self.get_encryption_key(secret_name)
return Fernet(key).decrypt(encrypted_data.encode()).decode("utf-8")
# Short usage
secrets_encryption = AWSSecretsEncryption()
secret_name = "prod/encryption/master-key"
token = secrets_encryption.encrypt_data("data", secret_name)
plaintext = secrets_encryption.decrypt_data(token, secret_name)
Why this works:
- Keys live outside the app; no hardcoding.
- IAM + CloudTrail enforce access and audit.
- Managed rotation supports lifecycle policy.
Migration from Weak to Strong Encryption
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from Crypto.Cipher import DES # Old weak encryption
import os
import base64
from enum import Enum
class EncryptionVersion(Enum):
"""Track encryption algorithm versions"""
V1_DES = "v1_des" # Legacy weak encryption
V2_AES256 = "v2_aes256" # Current strong encryption
def decrypt_legacy(ciphertext: str, des_key: bytes) -> str:
raw = base64.b64decode(ciphertext)
return DES.new(des_key, DES.MODE_ECB).decrypt(raw).decode().strip()
def encrypt_modern(plaintext: str, aes_key: bytes) -> str:
nonce = os.urandom(12)
ciphertext = AESGCM(aes_key).encrypt(nonce, plaintext.encode(), None)
return base64.b64encode(nonce + ciphertext).decode("utf-8")
def migrate(ciphertext: str, version: str, old_key: bytes, new_key: bytes) -> tuple[str, str]:
if version == EncryptionVersion.V1_DES.value:
plaintext = decrypt_legacy(ciphertext, old_key)
return encrypt_modern(plaintext, new_key), EncryptionVersion.V2_AES256.value
return ciphertext, version
# Short usage - the fixture has to be a real DES ciphertext. Passing a
# placeholder string raises ValueError from b64decode or from the ECB block
# alignment check, not a migration failure you can learn anything from.
old_key = b"old_key1"
new_key = os.urandom(32)
legacy = base64.b64encode(DES.new(old_key, DES.MODE_ECB).encrypt(b"secret ")).decode()
migrated, new_version = migrate(legacy, "v1_des", old_key, new_key)
Why this works:
- Version tags enable dual-read during rollout.
- Records can be re-encrypted on access or in batches.
- New data uses AES-GCM while legacy remains readable.
- The transition needs no downtime.
Testing
A scanner confirms the weak algorithm is gone. It cannot confirm the replacement works, and a key-strength change is unusually good at passing review while breaking data that already exists. Assert each of these:
- Ciphertext round-trips. Encrypt, then decrypt with a separately constructed object, and compare against the original plaintext. Reusing the encryptor instance hides the most common defect, where the nonce or tag is generated but never returned to the caller.
- Tampering is rejected. Flip one byte of the ciphertext, one byte of the
tag, and one byte of the nonce, and confirm each raises
InvalidTagrather than returning plaintext. An AEAD mode that decrypts modified input is not being authenticated. - The key size actually applied. Assert
private_key.key_size == 3072after generation rather than trusting the parameter, and assert the symmetric key length in bytes. A default silently substituted for a misspelled argument is invisible otherwise. - Data encrypted before the change still decrypts. Keep a fixture encrypted under the old algorithm and assert the dual-read path returns the original plaintext. This is the test that fails in production if it is missing.
- Old password hashes still verify, and are upgraded on use. Assert that a correct password checked against a stored legacy hash returns true, that the stored hash is then rewritten in the new format, and that the next verification uses the new format. Assert a wrong password still returns false at both stages.
- Password hashing is slow enough. Time one
verifycall and assert it falls in the intended range (roughly 250-500ms). A cost parameter that was lowered to speed up the test suite tends to reach production.
Additional Resources
- CWE-326: Inadequate Encryption Strength
- Django Password Hashers
- Flask-Bcrypt Documentation
- NIST Key Management Guidelines
- OWASP Cryptographic Storage Cheat Sheet
- OWASP Password Storage Cheat Sheet - the source for the PBKDF2 iteration count, bcrypt work factor, and Argon2 parameters used here
- Password Hashing Competition (Argon2)
- PyCryptodome Documentation
- Python Cryptography Library Documentation