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's rich cryptographic ecosystem includes multiple libraries (PyCryptodome, cryptography, hashlib), making it critical to select strong algorithms and appropriate key sizes.

Common Python Vulnerability Scenarios:

  • Using DES or 3DES instead of AES-256
  • Implementing AES with 128-bit keys instead of 256-bit keys
  • Using MD5 or SHA-1 for security-critical operations
  • Applying weak RSA key sizes (1024 bits or less)
  • Using ECB mode which doesn't provide semantic security
  • Relying on deprecated or custom cryptographic implementations

Python Cryptographic Landscape:

  • cryptography: Modern, comprehensive 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: Leverage passlib with bcrypt for password hashing

Primary Defence: Use cryptography library with Fernet or AES-256-GCM for symmetric encryption, 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

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.
  • NIST deprecated 1024-bit keys in 2013.
  • Minimum 2048-bit keys are required for modern security.

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 with Insufficient Key Size

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 technically secure but AES-256 is recommended
        self.key = get_random_bytes(16)  # 128 bits

    def encrypt_token(self, token):
        cipher = AES.new(self.key, AES.MODE_CBC)
        padded = pad(token.encode(), AES.block_size)
        encrypted = cipher.encrypt(padded)
        # IV is included but key strength is weak
        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:

  • While AES-128 is currently secure, AES-256 provides better long-term protection
  • Key is not derived from a password securely
  • No key rotation or versioning
  • Missing authentication (should use GCM mode)

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

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.
  • Fixed salts make identical passwords produce identical keys.
  • 128-bit keys provide less margin than 256-bit keys.

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

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-bit effective key

    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:

  • 3DES is deprecated (NIST retired it in 2023)
  • 64-bit block size vulnerable to birthday attacks
  • Slower than AES with no security benefit
  • Not compliant with modern security standards

Secure Patterns

AES-256-GCM Encryption

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

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 meets long-term guidance.
  • 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.

Argon2 for Password Hashing (Most Secure)

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 uses AES-128-CBC + HMAC-SHA256 with built-in AEAD
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.
  • Random IVs ensure non-deterministic ciphertexts.
  • Token includes timestamp for TTL checks.

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
old_key = b"old_key1"
new_key = os.urandom(32)
migrated, new_version = migrate("legacy_ciphertext", "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.
  • Transition avoids downtime and reduces risk.

Additional Resources