Skip to content

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_HASHERS for stronger options
  • Flask: No built-in encryption; use Flask-Bcrypt or cryptography library
  • FastAPI: Use pwdlib with Argon2, which is what FastAPI's own security tutorial now recommends. Do not reach for passlib: its last release was 1.7.4 in October 2020, and its bcrypt backend reads bcrypt.__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. passlib is 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_token will 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 PBKDF2 defaults to HMAC-SHA1 when no hmac_hash_module is 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=16 is 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.
  • checkpw performs 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: decrypt verifies the HMAC first and raises InvalidToken rather than returning modified plaintext.
  • Random IVs ensure non-deterministic ciphertexts.
  • Token includes a timestamp, which decrypt(token, ttl=seconds) will enforce. Without the ttl argument 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 AESGCM above 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 InvalidTag rather than returning plaintext. An AEAD mode that decrypts modified input is not being authenticated.
  • The key size actually applied. Assert private_key.key_size == 3072 after 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 verify call 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