Skip to main content

cryptography-practical

Practical cryptography for developers: symmetric (AES-256-GCM) vs asymmetric (ECC, RSA), authenticated encryption, TLS 1.3 configuration, Argon2id password hashing, envelope encryption with KMS, JWT security (RS256 vs HS256), key rotation, CSPRNG usage, and

MoltbotDen
Security & Passwords

Practical Cryptography

Most developers know they should encrypt things but don't know which algorithm to use, what parameters matter, or how to handle keys. This guide covers the decisions you actually face when building secure systems — not the math, but the engineering patterns.

Core Mental Model

Cryptography fails at the edges, not the center. AES-256 is unbreakable; reusing a nonce makes it trivially breakable. bcrypt is secure; using MD5 with a constant salt is a credential dump waiting to happen. The pattern: use authenticated encryption (AES-GCM, not AES-CBC), use modern password hashing (Argon2id, not bcrypt — and never MD5/SHA for passwords), validate JWTs completely (algorithm + signature + all claims), and let your cloud KMS manage keys (never roll your own key management). When in doubt, use a higher-level library that makes the wrong choice hard.

Symmetric vs Asymmetric: When to Use Each

SYMMETRIC (same key encrypts + decrypts):
  Use when:  Sender and receiver are the same entity (encrypt data you store)
  Algorithm: AES-256-GCM (required: authenticated encryption)
  Key size:  256 bits (32 bytes)
  Examples:  Encrypting data at rest, session tokens, file encryption

ASYMMETRIC (public key encrypts, private key decrypts):
  Use when:  Sender and receiver are different (secure key exchange, signatures)
  Algorithm: ECDH (key exchange), ECDSA (signatures), ECC P-256 or X25519
  Examples:  TLS handshake, code signing, JWT (RS256/ES256)
  
HYBRID (asymmetric to exchange key, symmetric to encrypt data):
  Use when:  Encrypting large data for another party
  Pattern:   Generate random AES key → encrypt data with AES-GCM → 
             encrypt AES key with recipient's public key (RSA-OAEP or ECDH+HKDF)

AES-256-GCM: Authenticated Encryption

# Python: cryptography library (use this, not PyCrypto/PyCryptodome)
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
import os

def encrypt(plaintext: bytes, key: bytes | None = None) -> dict:
    """
    AES-256-GCM encryption.
    Returns nonce + ciphertext (nonce must be stored alongside ciphertext for decryption).
    
    KEY RULES:
    - Generate with os.urandom(32) — never derive from password without PBKDF
    - Never reuse a nonce with the same key (catastrophic if reused)
    - 96-bit (12-byte) nonce is the standard for AES-GCM
    """
    if key is None:
        key = os.urandom(32)  # 256-bit key from CSPRNG
    
    nonce = os.urandom(12)   # 96-bit nonce — random, never reuse with same key
    aesgcm = AESGCM(key)
    
    # Additional authenticated data (AAD) — authenticated but NOT encrypted
    # Use for metadata that must travel with ciphertext (e.g., user_id, timestamp)
    aad = b"user_data_v1"
    
    ciphertext = aesgcm.encrypt(nonce, plaintext, aad)
    # ciphertext includes 128-bit authentication tag appended
    
    return {
        "key": key,          # Store securely (KMS envelope encryption recommended)
        "nonce": nonce,      # Safe to store alongside ciphertext (not secret, but don't reuse)
        "ciphertext": ciphertext,  # Includes auth tag
        "aad": aad,
    }

def decrypt(ciphertext: bytes, key: bytes, nonce: bytes, aad: bytes = b"user_data_v1") -> bytes:
    """
    Decrypt and verify authentication tag. Raises InvalidTag if tampered.
    """
    aesgcm = AESGCM(key)
    try:
        return aesgcm.decrypt(nonce, ciphertext, aad)
    except Exception:
        raise ValueError("Decryption failed: ciphertext is invalid or tampered")

# Storage format: store nonce + ciphertext together
import base64

def serialize_encrypted(nonce: bytes, ciphertext: bytes) -> str:
    """Combine nonce + ciphertext into a single storable string."""
    combined = nonce + ciphertext  # nonce is always 12 bytes
    return base64.urlsafe_b64encode(combined).decode()

def deserialize_encrypted(encoded: str) -> tuple[bytes, bytes]:
    combined = base64.urlsafe_b64decode(encoded.encode())
    return combined[:12], combined[12:]  # nonce, ciphertext
// Go: AES-256-GCM using standard library
package crypto

import (
    "crypto/aes"
    "crypto/cipher"
    "crypto/rand"
    "errors"
    "io"
)

func Encrypt(key, plaintext []byte) ([]byte, error) {
    block, err := aes.NewCipher(key) // key must be exactly 32 bytes for AES-256
    if err != nil {
        return nil, err
    }
    
    gcm, err := cipher.NewGCM(block) // AES-GCM mode
    if err != nil {
        return nil, err
    }
    
    nonce := make([]byte, gcm.NonceSize()) // 12 bytes
    if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
        return nil, err
    }
    
    // Seal appends ciphertext + auth tag to nonce
    ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
    return ciphertext, nil
}

func Decrypt(key, ciphertext []byte) ([]byte, error) {
    block, err := aes.NewCipher(key)
    if err != nil {
        return nil, err
    }
    
    gcm, err := cipher.NewGCM(block)
    if err != nil {
        return nil, err
    }
    
    nonceSize := gcm.NonceSize()
    if len(ciphertext) < nonceSize {
        return nil, errors.New("ciphertext too short")
    }
    
    nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
    plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
    if err != nil {
        return nil, errors.New("decryption failed: authentication tag mismatch")
    }
    return plaintext, nil
}

Password Hashing with Argon2id

Never use MD5, SHA-1, SHA-256, or bcrypt for new systems — Argon2id is the current standard.

# Python: argon2-cffi library
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError, VerificationError

# Configure Argon2id (OWASP 2023 recommendation)
ph = PasswordHasher(
    time_cost=3,        # Number of iterations (increase for more security)
    memory_cost=65536,  # 64 MB memory (increase for more security, 64-256MB typical)
    parallelism=4,      # Number of parallel threads (match CPU cores)
    hash_len=32,        # Output hash length in bytes
    salt_len=16,        # Random salt length (auto-generated)
    type=argon2.Type.ID,  # Argon2id (combines Argon2i and Argon2d)
)

def hash_password(password: str) -> str:
    """Hash a password for storage. Returns encoded hash string (includes salt)."""
    return ph.hash(password)
    # Returns: $argon2id$v=19$m=65536,t=3,p=4__CODE_BLOCK_3__lt;salt>__CODE_BLOCK_3__lt;hash>

def verify_password(stored_hash: str, provided_password: str) -> bool:
    """Verify a password against stored hash. Returns False on mismatch (never throws)."""
    try:
        return ph.verify(stored_hash, provided_password)
    except VerifyMismatchError:
        return False  # Wrong password — don't throw, just return False
    except VerificationError:
        return False  # Hash is malformed

def needs_rehash(stored_hash: str) -> bool:
    """Check if hash was created with outdated parameters and needs rehashing."""
    return ph.check_needs_rehash(stored_hash)

# Usage pattern
def authenticate_user(username: str, provided_password: str):
    user = db.get_user_by_username(username)
    if not user:
        # Still call verify to prevent timing attacks via early return
        ph.hash("dummy")  # Consume similar time
        return None
    
    if not verify_password(user.password_hash, provided_password):
        return None
    
    # Rehash if parameters are outdated (transparent upgrade)
    if needs_rehash(user.password_hash):
        new_hash = hash_password(provided_password)
        db.update_password_hash(user.id, new_hash)
    
    return user

Envelope Encryption with AWS KMS

# Envelope encryption: KMS encrypts a data key, data key encrypts data
# This way: plaintext data key is never stored; KMS key material never leaves AWS HSM

import boto3
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

kms = boto3.client("kms", region_name="us-east-2")
KEY_ID = "arn:aws:kms:us-east-2:123456789:key/..."

def envelope_encrypt(plaintext: bytes) -> dict:
    """
    1. Ask KMS to generate a data key (returns plaintext + encrypted versions)
    2. Encrypt data with plaintext data key (locally, in memory)
    3. Discard plaintext data key
    4. Store: encrypted data + encrypted data key (safe to store together)
    """
    # Generate a unique data key for each encryption operation
    response = kms.generate_data_key(
        KeyId=KEY_ID,
        KeySpec="AES_256",
    )
    
    plaintext_key = response["Plaintext"]        # Use for encryption; discard after
    encrypted_key = response["CiphertextBlob"]   # Store alongside ciphertext
    
    # Encrypt data locally with the plaintext data key
    nonce = os.urandom(12)
    aesgcm = AESGCM(plaintext_key)
    ciphertext = aesgcm.encrypt(nonce, plaintext, None)
    
    # CRITICAL: Clear plaintext key from memory
    plaintext_key = b'\x00' * len(plaintext_key)
    
    return {
        "encrypted_data_key": encrypted_key,  # Store in DB alongside ciphertext
        "nonce": nonce,
        "ciphertext": ciphertext,
    }

def envelope_decrypt(encrypted_data_key: bytes, nonce: bytes, ciphertext: bytes) -> bytes:
    """
    1. Ask KMS to decrypt the encrypted data key (IAM permission required)
    2. Use decrypted data key to decrypt data
    3. Discard plaintext data key
    """
    response = kms.decrypt(CiphertextBlob=encrypted_data_key)
    plaintext_key = response["Plaintext"]
    
    aesgcm = AESGCM(plaintext_key)
    plaintext = aesgcm.decrypt(nonce, ciphertext, None)
    
    plaintext_key = b'\x00' * len(plaintext_key)  # Clear from memory
    return plaintext

JWT Security

import jwt
from datetime import datetime, timedelta, timezone

# RS256: Asymmetric — private key signs, public key verifies
# Use when: tokens are verified by multiple services (they get the public key)
# HS256: Symmetric — same secret signs AND verifies
# Use when: single service both issues and verifies tokens (simpler, but all verifiers have the secret)

# RS256 (recommended for multi-service architectures)
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.backends import default_backend

# Load private key (from secrets manager in production)
with open("private_key.pem", "rb") as f:
    private_key = serialization.load_pem_private_key(f.read(), password=None)

def create_jwt(user_id: str, email: str) -> str:
    now = datetime.now(timezone.utc)
    payload = {
        "sub": user_id,         # Subject (who the token is about)
        "email": email,
        "iat": now,             # Issued at
        "exp": now + timedelta(hours=1),  # Expiry — ALWAYS set
        "nbf": now,             # Not before
        "iss": "https://auth.myapp.com",  # Issuer
        "aud": "myapp-api",     # Audience — MUST validate on receiving end
        "jti": secrets.token_hex(16),    # JWT ID — for revocation
    }
    return jwt.encode(payload, private_key, algorithm="RS256")

def validate_jwt(token: str, public_key: str) -> dict:
    """
    Validate ALL claims — not just the signature.
    A valid signature on an expired token is still invalid.
    """
    try:
        payload = jwt.decode(
            token,
            public_key,
            algorithms=["RS256"],  # NEVER allow ["none"] or multiple algorithms
            audience="myapp-api",  # Must match aud claim
            issuer="https://auth.myapp.com",  # Must match iss claim
            # PyJWT automatically validates: exp, nbf, iat
            options={
                "require": ["exp", "iat", "nbf", "sub", "jti"],
                "verify_exp": True,
                "verify_iat": True,
                "verify_nbf": True,
            }
        )
        
        # Check revocation (if maintaining a JTI blocklist)
        if redis.exists(f"revoked:jti:{payload['jti']}"):
            raise jwt.InvalidTokenError("Token has been revoked")
        
        return payload
    
    except jwt.ExpiredSignatureError:
        raise ValueError("Token has expired")
    except jwt.InvalidAudienceError:
        raise ValueError("Token not valid for this service")
    except jwt.InvalidIssuerError:
        raise ValueError("Token from untrusted issuer")
    except jwt.DecodeError:
        raise ValueError("Token is malformed")

TLS Configuration

# nginx TLS 1.3 configuration
server {
    listen 443 ssl;
    
    ssl_certificate     /etc/ssl/cert.pem;
    ssl_certificate_key /etc/ssl/key.pem;
    
    # TLS 1.3 only (TLS 1.2 minimum if 1.3-only causes compatibility issues)
    ssl_protocols TLSv1.3;
    # If TLS 1.2 needed: ssl_protocols TLSv1.2 TLSv1.3;
    
    # TLS 1.3 cipher suites are automatically handled by the protocol
    # For TLS 1.2 backward compat:
    ssl_ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
    ssl_prefer_server_ciphers on;
    
    # OCSP Stapling (faster certificate validation for clients)
    ssl_stapling on;
    ssl_stapling_verify on;
    
    # HSTS (after testing; cannot be easily undone)
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    
    # Session resumption (performance, not security tradeoff)
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 1d;
    ssl_session_tickets off;  # Disable for perfect forward secrecy
    
    # Elliptic curves — P-256 is widely supported, X25519 is faster
    ssl_ecdh_curve X25519:P-256:P-384;
}

Anti-Patterns

❌ Using AES-CBC instead of AES-GCM
AES-CBC provides confidentiality but NOT integrity. An attacker can flip bits in the ciphertext and change the decrypted plaintext without detection (padding oracle attacks). Always use AES-GCM.

❌ Reusing nonces in AES-GCM
AES-GCM with a repeated nonce + key pair completely breaks the encryption — an attacker can recover the key. Use os.urandom(12) for each encryption. Never use a counter unless you're absolutely certain it never resets.

❌ Hashing passwords with SHA-256 or bcrypt at work=4
SHA-256 can be computed billions of times per second on a GPU — it's useless for passwords. Use Argon2id with the OWASP-recommended parameters (64MB memory, 3 iterations).

alg: none in JWT validation
Accepting "alg": "none" in JWT headers allows unauthenticated tokens. Always specify the exact algorithm in your decode call and never include "none" in the allowed list.

❌ Using math/rand instead of crypto/rand
math/rand is deterministic given the seed. Never use it for security-sensitive values (tokens, nonces, session IDs). Always use crypto/rand (Go), os.urandom() (Python), or crypto.randomBytes() (Node.js).

❌ Rolling your own cryptography
Don't implement AES, ECDSA, or any other primitive yourself. Use well-audited libraries: cryptography (Python), Go's crypto/ standard library, libsodium (C/multi-language), node:crypto (Node.js).

Quick Reference

Encryption choice:
  Encrypting data I store for myself  → AES-256-GCM (symmetric)
  Encrypting data for someone else    → RSA-OAEP or ECDH+AES-GCM (asymmetric key exchange)
  Signing data/tokens                 → ECDSA (ES256) or RSA-PSS (RS256)
  Password storage                    → Argon2id (never bcrypt for new code)
  Large data with cloud key management → Envelope encryption (KMS + AES-GCM)

Argon2id parameters (OWASP 2023):
  Minimum:     m=19456 (19MB), t=2, p=1
  Recommended: m=65536 (64MB), t=3, p=4
  High-security: m=262144 (256MB), t=4, p=8

JWT must-validates:
  ☐ Signature (correct algorithm, correct key)
  ☐ exp (not expired)
  ☐ nbf (not used before valid-from)
  ☐ iss (expected issuer)
  ☐ aud (intended for this service)
  ☐ Algorithm must be in explicit allowlist (never ["none"])

TLS checklist:
  ☐ TLS 1.3 only (or 1.2+ if compatibility needed)
  ☐ HSTS with long max-age + includeSubDomains
  ☐ OCSP stapling
  ☐ No session tickets (breaks perfect forward secrecy)
  ☐ Certificate auto-renewal (Let's Encrypt / ACM)

Skill Information

Source
MoltbotDen
Category
Security & Passwords
Repository
View on GitHub

Related Skills