Skip to content

Cryptography Fundamentals

Table of Contents

Foundation Layer

Symmetric Cryptography

Asymmetric Cryptography

Hash Functions and MACs

Advanced Topics


Understanding Cryptography

Crypto keeps secrets in a world where everything leaks

You send data across networks every single day , passwords , credit cards , private messages , all bouncing through routers you don't control and ISPs you don't trust , without cryptography every bit of that data would be readable by anyone sitting in the middle

Think about what happens when you visit a website , your browser connects to your router , router talks to your ISP , ISP routes through backbone providers , eventually hits the destination server , at every hop someone could be watching , logging , modifying

Cryptography solves three problems , confidentiality (only authorized people read it) , integrity (detect if someone changed it) , authenticity (prove who sent it)

Modern crypto isn't optional anymore , it's the foundation of everything digital , HTTPS wouldn't exist without TLS , Bitcoin wouldn't work without ECDSA , your encrypted disk relies on AES , SSH sessions use Diffie-Hellman

The math behind crypto is actually simple once you break it down , modular arithmetic (clock math) , prime numbers (building blocks) , one-way functions (easy forward , impossible backward)

Core Principles:

  • Kerckhoffs's Principle: security relies on key secrecy , not algorithm secrecy
  • Never roll your own crypto , use tested libraries
  • Key management matters more than the algorithm
  • Crypto doesn't fix bad system design

Real-World Impact:

Without crypto: passwords transmitted as plaintext , credit cards stolen in transit , governments reading everything , no private communication possible

With crypto: end-to-end encrypted messaging , secure online banking , private browsing , digital signatures proving authenticity

Command to check TLS:

openssl s_client -connect google.com:443 -servername google.com

This initiates a TLS handshake with Google , shows cipher suites negotiated , certificate chain , protocol version , useful for debugging HTTPS issues


Why Crypto Matters

Every unencrypted packet is a postcard anyone can read

Picture sending a physical letter , you write it , put it in an envelope , mail it , only the recipient opens it , now imagine sending that same letter as a postcard , everyone who handles it can read every word

That's the internet without encryption , every packet is a postcard

Threat Model:

  • Passive attackers: ISPs logging traffic , governments doing mass surveillance , coffee shop WiFi sniffers
  • Active attackers: man-in-the-middle attacks , packet injection , DNS hijacking
  • Insider threats: compromised routers , malicious sysadmins , supply chain attacks

What Crypto Protects:

  • Passwords: hashed with bcrypt/Argon2 so even database dumps don't leak plaintext
  • Financial data: credit cards encrypted in transit and at rest
  • Private communications: Signal/WhatsApp using end-to-end encryption
  • Disk contents: full disk encryption with LUKS/BitLocker
  • Code integrity: digital signatures proving software hasn't been tampered with

What Crypto Doesn't Protect:

  • Metadata: who talks to who , when , how often (traffic analysis still works)
  • Endpoint security: if your device is compromised , crypto can't help
  • Social engineering: tricking users into giving up keys
  • Implementation bugs: heartbleed , padding oracle , timing attacks

Linux Command to Encrypt Files:

gpg --symmetric --cipher-algo AES256 sensitive_file.txt

This encrypts a file with AES-256 using a passphrase , creates sensitive_file.txt.gpg , decrypt with gpg -d sensitive_file.txt.gpg


Classical Ciphers

Ancient crypto that taught us how encryption works

These ciphers are broken , completely insecure , don't use them for anything real , but they're perfect for understanding the fundamentals before diving into AES and RSA

Classical ciphers show up in CTF challenges constantly , understanding them helps you recognize weak crypto in the wild , and honestly they're just fun to play with

Why Learn Broken Crypto:

  • Understand substitution vs transposition concepts
  • Recognize patterns in ciphertext
  • Appreciate how far modern crypto has come
  • Solve CTF puzzles that use them as first layers

Caesar Cipher

Shift every letter by a fixed number

Julius Caesar used this for military messages over 2000 years ago , it's the simplest cipher that exists , shift A to D , B to E , wrap Z back to C

How it works:

Plaintext:  HELLO WORLD
Shift +3:   KHOOR ZRUOG

Each letter shifts forward 3 positions in the alphabet , H becomes K , E becomes H , L becomes O

Why it's broken:

Only 25 possible keys (shift 1 through 25) , brute force takes seconds , frequency analysis breaks it instantly (E is most common in English)

Python Implementation:

def caesar_encrypt(text , shift):
    result = ""
    for char in text.upper():
        if char.isalpha():
            shifted = (ord(char) - ord('A') + shift) % 26 + ord('A')
            result += chr(shifted)
        else:
            result += char
    return result

def caesar_decrypt(text , shift):
    return caesar_encrypt(text , -shift)

print(caesar_encrypt("ATTACK AT DAWN" , 3))

Output: DWWDFN DW GDZQ

One-liner decrypt:

echo "KHOOR" | tr 'A-Z' 'X-ZA-W'

This shifts back 3 positions using tr command , quick and dirty Caesar decryption

Brute Force Attack:

def caesar_bruteforce(ciphertext):
    for shift in range(26):
        print(f"Shift {shift}: {caesar_decrypt(ciphertext , shift)}")

caesar_bruteforce("KHOOR ZRUOG")

Tries all 25 possible shifts , one of them will be readable English


Substitution Ciphers

Replace each letter with another letter

More complex than Caesar , uses a full substitution alphabet instead of just shifting , each letter maps to a different letter

Example Mapping:

Plain:  ABCDEFGHIJKLMNOPQRSTUVWXYZ
Cipher: ZEBRASCDFGHIJKLMNOPQTUVWXY

A becomes Z , B becomes E , C becomes B , and so on

Why it's broken:

Frequency analysis destroys it , E is most common in English (12.7%) , if X appears 12.7% in ciphertext , X probably represents E

Python Implementation:

def substitution_encrypt(text , key):
    alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
    result = ""
    for char in text.upper():
        if char in alphabet:
            result += key[alphabet.index(char)]
        else:
            result += char
    return result

key = "ZEBRASCDFGHIJKLMNOPQTUVWXY"
print(substitution_encrypt("HELLO" , key))

Frequency Analysis Attack:

from collections import Counter

def frequency_analysis(ciphertext):
    letters = [c for c in ciphertext.upper() if c.isalpha()]
    freq = Counter(letters)
    return freq.most_common(10)

ciphertext = "ITSSG VGKSR"
print(frequency_analysis(ciphertext))

Most common letter in ciphertext likely represents E , second most likely T , work backwards from there


Vigenère Cipher

Polyalphabetic substitution using a keyword

Instead of shifting by the same amount (Caesar) , shift by different amounts based on a repeating keyword , this was considered unbreakable for centuries

How it works:

Plaintext:  ATTACKATDAWN
Key:        LEMONLEMONLE
Ciphertext: LXFOPVEFRNHR

First letter: A + L = L , second letter: T + E = X , key repeats

Python Implementation:

def vigenere_encrypt(text , key):
    result = ""
    key = key.upper()
    key_index = 0
    for char in text.upper():
        if char.isalpha():
            shift = ord(key[key_index % len(key)]) - ord('A')
            result += chr((ord(char) - ord('A') + shift) % 26 + ord('A'))
            key_index += 1
        else:
            result += char
    return result

def vigenere_decrypt(text , key):
    result = ""
    key = key.upper()
    key_index = 0
    for char in text.upper():
        if char.isalpha():
            shift = ord(key[key_index % len(key)]) - ord('A')
            result += chr((ord(char) - ord('A') - shift) % 26 + ord('A'))
            key_index += 1
        else:
            result += char
    return result

print(vigenere_encrypt("ATTACK" , "LEMON"))

Breaking Vigenère:

Find key length using Kasiski examination or index of coincidence , once you know key length , split ciphertext into groups and use frequency analysis on each group

def kasiski_examination(ciphertext):
    # Find repeated sequences
    sequences = {}
    for length in range(3 , 6):
        for i in range(len(ciphertext) - length):
            seq = ciphertext[i:i+length]
            if seq in sequences:
                sequences[seq].append(i)
            else:
                sequences[seq] = [i]

    # Calculate distances between repetitions
    for seq , positions in sequences.items():
        if len(positions) > 1:
            distances = [positions[i+1] - positions[i] for i in range(len(positions)-1)]
            print(f"{seq}: distances {distances}")

Transposition Ciphers

Rearrange letters instead of substituting them

Unlike substitution ciphers that replace letters , transposition ciphers shuffle them around , same letters appear but in different positions

Rail Fence Cipher:

Plaintext: WEAREDISCOVEREDFLEEATONCE

Rails (3):
W . . . E . . . C . . . R . . . L . . . T . . . E
. E . R . D . S . O . E . E . F . E . A . O . C .
. . A . . . I . . . V . . . D . . . E . . . N . .

Ciphertext: WECRLTEERDSOEEFEAOCAIVDEN

Python Implementation:

def railfence_encrypt(text , rails):
    fence = [[] for _ in range(rails)]
    rail = 0
    direction = 1

    for char in text:
        fence[rail].append(char)
        rail += direction
        if rail == 0 or rail == rails - 1:
            direction *= -1

    return ''.join([''.join(rail) for rail in fence])

print(railfence_encrypt("WEAREDISCOVERED" , 3))

Columnar Transposition:

Write message in rows , read in columns based on key order

Key:      4 3 1 2 5
Message:  W E A R E
          D I S C O
          V E R E D

Read columns in order 1,2,3,4,5: ASREIEEDWVCRO

One-Time Pad

The only provably unbreakable cipher

XOR plaintext with a truly random key that's as long as the message , used once and destroyed , mathematically proven to be unbreakable if used correctly

How it works:

Plaintext:  HELLO (binary: 01001000 01000101 01001100 01001100 01001111)
Random key: 10110101 11001010 00110011 10101010 11110000
XOR result: 11111101 10001111 01111111 11100110 10111111

Why it's unbreakable:

Every possible plaintext is equally likely , no frequency analysis works , no pattern exists , attacker gains zero information

Why nobody uses it:

  • Key must be truly random (hard to generate)
  • Key must be as long as message (impractical for large data)
  • Key must never be reused (key management nightmare)
  • Key must be securely distributed (defeats the purpose)

Python Implementation:

import os

def otp_encrypt(plaintext):
    key = os.urandom(len(plaintext))
    ciphertext = bytes([p ^ k for p , k in zip(plaintext , key)])
    return ciphertext , key

def otp_decrypt(ciphertext , key):
    return bytes([c ^ k for c , k in zip(ciphertext , key)])

message = b"SECRET"
ciphertext , key = otp_encrypt(message)
print(f"Ciphertext: {ciphertext.hex()}")
print(f"Decrypted: {otp_decrypt(ciphertext , key)}")

Real-world usage:

Soviet spy agencies used OTP for decades , Moscow-Washington hotline used it , modern systems don't because key distribution is impossible at scale


Mathematical Foundations

The math that makes modern crypto work

You don't need a PhD in mathematics to use crypto , but understanding the basics helps you understand why things work and when they break

Core Concepts:

  • Modular arithmetic: clock math where numbers wrap around
  • Prime numbers: building blocks of RSA and Diffie-Hellman
  • One-way functions: easy to compute , impossible to reverse
  • Trapdoor functions: easy with secret , hard without it

Modular Arithmetic

Clock math that wraps numbers around

Think of a 12-hour clock , 13 o'clock is 1 o'clock , 25 o'clock is 1 o'clock , that's modular arithmetic

Notation:

13 mod 12 = 1
25 mod 12 = 1
100 mod 7 = 2

Properties:

(a + b) mod m = ((a mod m) + (b mod m)) mod m
(a * b) mod m = ((a mod m) * (b mod m)) mod m
(a ^ b) mod m = computed using modular exponentiation

Modular Inverse:

Number x where (a * x) mod m = 1

Example: inverse of 3 mod 11 is 4 because (3 * 4) mod 11 = 12 mod 11 = 1

Python Implementation:

def mod_inverse(a , m):
    # Extended Euclidean Algorithm
    m0 , x0 , x1 = m , 0 , 1
    while a > 1:
        q = a // m
        m , a = a % m , m
        x0 , x1 = x1 - q * x0 , x0
    return x1 + m0 if x1 < 0 else x1

print(mod_inverse(3 , 11))  # Output: 4

Modular Exponentiation:

Compute (base ^ exp) mod m efficiently without calculating the full power

def mod_pow(base , exp , mod):
    result = 1
    base %= mod
    while exp > 0:
        if exp & 1:
            result = (result * base) % mod
        base = (base * base) % mod
        exp >>= 1
    return result

print(mod_pow(2 , 100 , 1000))  # Output: 376

This is the core operation in RSA encryption and decryption


Prime Numbers

The foundation of public key cryptography

Prime numbers are only divisible by 1 and themselves , 2 , 3 , 5 , 7 , 11 , 13 , 17 , 19 , and so on

Why Primes Matter:

  • RSA security relies on difficulty of factoring large primes
  • Diffie-Hellman uses prime modulus for discrete logarithm
  • Elliptic curves use prime field arithmetic

Primality Testing:

Miller-Rabin test is probabilistic but fast , runs k rounds to increase confidence

import random

def miller_rabin(n , k=5):
    if n < 2:
        return False
    if n == 2 or n == 3:
        return True
    if n % 2 == 0:
        return False

    # Write n-1 as 2^r * d
    r , d = 0 , n - 1
    while d % 2 == 0:
        r += 1
        d //= 2

    # Witness loop
    for _ in range(k):
        a = random.randint(2 , n - 2)
        x = pow(a , d , n)
        if x == 1 or x == n - 1:
            continue
        for _ in range(r - 1):
            x = pow(x , 2 , n)
            if x == n - 1:
                break
        else:
            return False
    return True

print(miller_rabin(97))  # True
print(miller_rabin(100))  # False

Generating Large Primes:

def generate_prime(bits):
    while True:
        candidate = random.getrandbits(bits)
        candidate |= (1 << bits - 1) | 1  # Set MSB and LSB
        if miller_rabin(candidate):
            return candidate

prime = generate_prime(512)
print(f"512-bit prime: {prime}")

Command to generate prime:

openssl prime -generate -bits 512

Generates a 512-bit prime number using OpenSSL


Euler's Theorem

Foundation of RSA mathematics

Euler's theorem states: if gcd(a,n) = 1 then a^φ(n) ≡ 1 (mod n)

Where φ(n) is Euler's totient function counting numbers less than n that are coprime to n

For prime p:

φ(p) = p - 1

For two primes p and q:

φ(p*q) = (p-1)(q-1)

Why RSA works:

m^(e*d) ≡ m (mod n)

Because: e*d ≡ 1 (mod φ(n))
So: e*d = k*φ(n) + 1
Therefore: m^(e*d) = m^(k*φ(n)+1) = (m^φ(n))^k * m ≡ 1^k * m ≡ m (mod n)

Python Implementation:

def euler_totient(n):
    result = n
    p = 2
    while p * p <= n:
        if n % p == 0:
            while n % p == 0:
                n //= p
            result -= result // p
        p += 1
    if n > 1:
        result -= result // n
    return result

print(euler_totient(36))  # Output: 12
print(euler_totient(17))  # Output: 16 (prime-1)

Discrete Logarithm

The hard problem behind Diffie-Hellman

Given g^x ≡ h (mod p) , finding x is the discrete logarithm problem

Easy direction: compute g^x mod p using modular exponentiation Hard direction: given g, h, p find x (no efficient algorithm known)

Example:

g = 5, p = 23
5^6 mod 23 = 8  (easy to compute)

Given: 5^x ≡ 8 (mod 23), find x
Answer: x = 6 (hard to find without trying all values)

Baby-Step Giant-Step Algorithm:

import math

def discrete_log(g, h, p):
    n = int(math.sqrt(p)) + 1

    # Baby steps: compute g^j for j in [0, n)
    baby = {}
    power = 1
    for j in range(n):
        baby[power] = j
        power = (power * g) % p

    # Giant steps: compute h * (g^-n)^i
    factor = pow(g, n * (p - 2), p)  # g^-n mod p
    gamma = h
    for i in range(n):
        if gamma in baby:
            return i * n + baby[gamma]
        gamma = (gamma * factor) % p

    return None

print(discrete_log(5, 8, 23))  # Output: 6

This algorithm has complexity O(√p) instead of O(p)


Chinese Remainder Theorem

Solving simultaneous modular equations

Given system:

x ≡ a1 (mod n1)
x ≡ a2 (mod n2)
...
x ≡ ak (mod nk)

If all ni are pairwise coprime, unique solution exists modulo N = n1*n2*...*nk

Example:

x ≡ 2 (mod 3)
x ≡ 3 (mod 5)
x ≡ 2 (mod 7)

Solution: x = 23

Python Implementation:

def crt(remainders, moduli):
    total = 0
    prod = 1
    for m in moduli:
        prod *= m

    for r, m in zip(remainders, moduli):
        p = prod // m
        total += r * mod_inverse(p, m) * p

    return total % prod

remainders = [2, 3, 2]
moduli = [3, 5, 7]
print(crt(remainders, moduli))  # Output: 23

Used in RSA-CRT:

Speeds up RSA decryption by computing modulo p and q separately then combining


Fermat's Little Theorem

Special case of Euler's theorem for primes

If p is prime and a is not divisible by p:

a^(p-1) ≡ 1 (mod p)

Useful corollary:

a^p ≡ a (mod p)

Computing modular inverse:

a^-1 ≡ a^(p-2) (mod p)

Python example:

def fermat_inverse(a, p):
    return pow(a, p - 2, p)

print(fermat_inverse(3, 11))  # Output: 4
print((3 * 4) % 11)  # Verify: 1

Primality test:

If a^(n-1) ≡ 1 (mod n) for random a, n is probably prime

def fermat_test(n, k=5):
    if n < 2:
        return False
    for _ in range(k):
        a = random.randint(2, n - 2)
        if pow(a, n - 1, n) != 1:
            return False
    return True

Note: Carmichael numbers fool this test, use Miller-Rabin instead


Symmetric Encryption Basics

Same key encrypts and decrypts

Symmetric crypto is fast, efficient, perfect for bulk data, but has one problem: how do you share the key securely?

Key characteristics:

  • Single shared secret key
  • Fast encryption/decryption (hardware acceleration available)
  • Small key sizes (128-256 bits)
  • Key distribution problem (need secure channel)

Two main types:

Stream ciphers: encrypt bit-by-bit or byte-by-byte Block ciphers: encrypt fixed-size blocks (usually 128 bits)

Common algorithms:

  • AES: industry standard, hardware accelerated
  • ChaCha20: modern stream cipher, software-friendly
  • 3DES: legacy, being phased out
  • Blowfish/Twofish: older alternatives

When to use:

  • Encrypting files on disk
  • Database encryption
  • VPN tunnels
  • TLS session encryption (after key exchange)

Python example with AES:

from Crypto.Cipher import AES
from Crypto.Random import get_random_bytes

key = get_random_bytes(32)  # 256-bit key
cipher = AES.new(key, AES.MODE_GCM)
nonce = cipher.nonce

plaintext = b"Secret message"
ciphertext, tag = cipher.encrypt_and_digest(plaintext)

# Decrypt
decipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
decrypted = decipher.decrypt_and_verify(ciphertext, tag)
print(decrypted)

Stream Ciphers

Encrypt one bit or byte at a time

Stream ciphers generate a keystream (pseudo-random sequence) and XOR it with plaintext

How they work:

Plaintext:  01001000 01000101 01001100
Keystream:  10110101 11001010 00110011
Ciphertext: 11111101 10001111 01111111

Advantages:

  • No padding needed
  • Can encrypt data of any length
  • Low memory footprint
  • Fast in software

Disadvantages:

  • Never reuse key/nonce pair (catastrophic failure)
  • No error propagation (bit flip in ciphertext = bit flip in plaintext)
  • Vulnerable to bit-flipping attacks without authentication

Modern stream ciphers:

  • ChaCha20: used in TLS, SSH, WireGuard
  • Salsa20: predecessor to ChaCha20
  • AES-CTR: block cipher in counter mode (acts like stream cipher)

RC4 Cipher

Broken stream cipher still found in legacy systems

RC4 was designed in 1987, widely used in WEP and early TLS, now completely broken

How RC4 works:

  1. Key Scheduling Algorithm (KSA): initialize 256-byte state array
  2. Pseudo-Random Generation Algorithm (PRGA): generate keystream bytes
  3. XOR keystream with plaintext

Python implementation:

def rc4(key, data):
    # KSA
    S = list(range(256))
    j = 0
    for i in range(256):
        j = (j + S[i] + key[i % len(key)]) % 256
        S[i], S[j] = S[j], S[i]

    # PRGA
    i = j = 0
    keystream = []
    for _ in range(len(data)):
        i = (i + 1) % 256
        j = (j + S[i]) % 256
        S[i], S[j] = S[j], S[i]
        K = S[(S[i] + S[j]) % 256]
        keystream.append(K)

    return bytes([d ^ k for d, k in zip(data, keystream)])

key = b"secretkey"
plaintext = b"attack at dawn"
ciphertext = rc4(key, plaintext)
print(ciphertext.hex())

# Decrypt (same operation)
decrypted = rc4(key, ciphertext)
print(decrypted)

Why it's broken:

  • Biased keystream (certain bytes more likely)
  • Weak key scheduling
  • Related-key attacks
  • First bytes of keystream are non-random

Never use RC4 in new systems


ChaCha20

Modern stream cipher replacing RC4

Designed by Daniel Bernstein in 2008, now used in TLS 1.3, WireGuard, and SSH

Advantages over RC4:

  • No known biases in keystream
  • Constant-time implementation (resistant to timing attacks)
  • Fast in software (no lookup tables)
  • 256-bit key, 96-bit nonce

How it works:

ChaCha20 uses 20 rounds of quarter-round operations on a 512-bit state

State layout:

cccccccc  cccccccc  cccccccc  cccccccc  (constants)
kkkkkkkk  kkkkkkkk  kkkkkkkk  kkkkkkkk  (key)
kkkkkkkk  kkkkkkkk  kkkkkkkk  kkkkkkkk  (key)
bbbbbbbb  nnnnnnnn  nnnnnnnn  nnnnnnnn  (counter + nonce)

Python implementation (simplified):

def rotl(a, b):
    return ((a << b) | (a >> (32 - b))) & 0xffffffff

def quarter_round(a, b, c, d):
    a = (a + b) & 0xffffffff; d ^= a; d = rotl(d, 16)
    c = (c + d) & 0xffffffff; b ^= c; b = rotl(b, 12)
    a = (a + b) & 0xffffffff; d ^= a; d = rotl(d, 8)
    c = (c + d) & 0xffffffff; b ^= c; b = rotl(b, 7)
    return a, b, c, d

# Full ChaCha20 implementation is complex
# Use library: from Crypto.Cipher import ChaCha20

Using ChaCha20:

from Crypto.Cipher import ChaCha20

key = get_random_bytes(32)
nonce = get_random_bytes(12)
cipher = ChaCha20.new(key=key, nonce=nonce)

plaintext = b"Encrypted with ChaCha20"
ciphertext = cipher.encrypt(plaintext)

# Decrypt
decipher = ChaCha20.new(key=key, nonce=nonce)
decrypted = decipher.decrypt(ciphertext)
print(decrypted)

Command line encryption:

echo "secret message" | openssl enc -chacha20 -k "password" -pbkdf2 -base64

Salsa20

ChaCha20's predecessor

Similar design to ChaCha20 but with different quarter-round function

Differences from ChaCha20:

  • Different diffusion pattern
  • Slightly less secure (ChaCha20 improves diffusion)
  • Still secure for most applications

Variants:

  • Salsa20/20: full 20 rounds
  • Salsa20/12: 12 rounds (faster, still secure)
  • Salsa20/8: 8 rounds (fastest, reduced security margin)

Python with Salsa20:

from Crypto.Cipher import Salsa20

key = get_random_bytes(32)
cipher = Salsa20.new(key=key)
nonce = cipher.nonce

ciphertext = cipher.encrypt(b"Message")

decipher = Salsa20.new(key=key, nonce=nonce)
print(decipher.decrypt(ciphertext))

Block Ciphers

Encrypt fixed-size blocks of data

Block ciphers operate on fixed-size blocks (usually 128 bits), applying multiple rounds of substitution and permutation

Core concepts:

  • Block size: typically 128 bits (16 bytes)
  • Key size: 128, 192, or 256 bits
  • Rounds: multiple iterations (10-14 for AES)
  • Padding: required when plaintext isn't multiple of block size

Confusion and Diffusion:

  • Confusion: relationship between ciphertext and key is complex
  • Diffusion: changing one bit of plaintext affects many bits of ciphertext

Common block ciphers:

  • AES (Rijndael): current standard
  • DES: obsolete (56-bit key too small)
  • 3DES: legacy (slow, being phased out)
  • Blowfish: older alternative
  • Twofish: AES finalist

DES and 3DES

Legacy ciphers being phased out

DES (Data Encryption Standard) was the standard from 1977 to 2001, now broken due to 56-bit key size

DES structure:

  • 64-bit block size
  • 56-bit key (8 bytes with parity bits)
  • 16 rounds of Feistel network
  • Broken by brute force in 1998

3DES (Triple DES):

Apply DES three times with different keys:

Ciphertext = DES_encrypt(K3, DES_decrypt(K2, DES_encrypt(K1, Plaintext)))

Effective key length: 168 bits (3 × 56)

Python DES example:

from Crypto.Cipher import DES3

key = b'DESCRYPT'  # 8 bytes for DES
# For 3DES, need 24 bytes
key3 = DES3.adjust_key_parity(b'TWENTYFOUR_BYTE_KEY!!!!!')
cipher = DES3.new(key3, DES3.MODE_ECB)

plaintext = b'8bytesss'
ciphertext = cipher.encrypt(plaintext)
print(ciphertext.hex())

Why not to use DES:

  • 56-bit keys broken by brute force
  • 64-bit blocks vulnerable to birthday attacks
  • Slow compared to AES
  • No hardware acceleration on modern CPUs

3DES deprecation:

NIST deprecated 3DES in 2023, migrate to AES


AES Algorithm

The encryption standard everyone uses

AES (Advanced Encryption Standard) replaced DES in 2001, now the global standard for symmetric encryption

Key features:

  • Block size: 128 bits (16 bytes)
  • Key sizes: 128, 192, or 256 bits
  • Rounds: 10 (AES-128), 12 (AES-192), 14 (AES-256)
  • Hardware acceleration on modern CPUs (AES-NI)

Why AES won:

  • Fast in both hardware and software
  • No known practical attacks
  • Simple structure (easy to implement)
  • Flexible key sizes

AES operations:

  1. SubBytes: substitute bytes using S-box
  2. ShiftRows: shift rows cyclically
  3. MixColumns: mix columns using matrix multiplication
  4. AddRoundKey: XOR with round key

Python AES encryption:

from Crypto.Cipher import AES
from Crypto.Random import get_random_bytes
from Crypto.Util.Padding import pad, unpad

key = get_random_bytes(16)  # 128-bit key
cipher = AES.new(key, AES.MODE_ECB)

plaintext = b"Hello AES"
padded = pad(plaintext, AES.block_size)
ciphertext = cipher.encrypt(padded)

decipher = AES.new(key, AES.MODE_ECB)
decrypted = unpad(decipher.decrypt(ciphertext), AES.block_size)
print(decrypted)

Command line AES:

# Encrypt file
openssl enc -aes-256-cbc -salt -in file.txt -out file.enc -k password

# Decrypt file
openssl enc -d -aes-256-cbc -in file.enc -out file.txt -k password

AES Internals

How AES actually works under the hood

AES operates on a 4×4 matrix of bytes called the state

State representation:

Plaintext: 00 11 22 33 44 55 66 77 88 99 aa bb cc dd ee ff

State matrix:
00 44 88 cc
11 55 99 dd
22 66 aa ee
33 77 bb ff

SubBytes transformation:

Replace each byte using S-box (substitution box)

# Simplified S-box (first 16 values)
sbox = [
    0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5,
    0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76
]

def sub_bytes(state):
    return [[sbox[byte] for byte in row] for row in state]

ShiftRows transformation:

Row 0: no shift
Row 1: shift left 1
Row 2: shift left 2
Row 3: shift left 3

Before:        After:
00 44 88 cc    00 44 88 cc
11 55 99 dd    55 99 dd 11
22 66 aa ee    aa ee 22 66
33 77 bb ff    ff 33 77 bb

MixColumns transformation:

Multiply each column by fixed matrix in GF(2^8)

[02 03 01 01]   [s0]
[01 02 03 01] × [s1]
[01 01 02 03]   [s2]
[03 01 01 02]   [s3]

AddRoundKey:

XOR state with round key derived from main key

def add_round_key(state, round_key):
    return [[state[i][j] ^ round_key[i][j] for j in range(4)] for i in range(4)]

Key expansion:

Generate round keys from main key using Rijndael key schedule

For AES-128: 128-bit key expands to 11 round keys (176 bytes total)


Block Cipher Modes

How to encrypt data longer than one block

Block ciphers only encrypt fixed-size blocks, modes of operation handle multiple blocks

Common modes:

  • ECB: Electronic Codebook (insecure, don't use)
  • CBC: Cipher Block Chaining
  • CTR: Counter mode
  • GCM: Galois/Counter Mode (authenticated)
  • CFB: Cipher Feedback
  • OFB: Output Feedback

ECB Mode

Electronic Codebook - the insecure mode

Each block encrypted independently with same key

C1 = E(K, P1)
C2 = E(K, P2)
C3 = E(K, P3)

Why it's broken:

Identical plaintext blocks produce identical ciphertext blocks, patterns visible

Famous example: ECB penguin image shows original structure

Python ECB:

cipher = AES.new(key, AES.MODE_ECB)
ciphertext = cipher.encrypt(pad(plaintext, 16))

Never use ECB for anything except testing


CBC Mode

Cipher Block Chaining - XOR with previous ciphertext

Each plaintext block XORed with previous ciphertext before encryption

C1 = E(K, P1 ⊕ IV)
C2 = E(K, P2 ⊕ C1)
C3 = E(K, P3 ⊕ C2)

Advantages:

  • Identical blocks produce different ciphertext
  • Error propagation (good for integrity)
  • Widely supported

Disadvantages:

  • Sequential (can't parallelize encryption)
  • Padding oracle attacks possible
  • Requires random IV

Python CBC:

from Crypto.Cipher import AES
from Crypto.Random import get_random_bytes

key = get_random_bytes(16)
iv = get_random_bytes(16)
cipher = AES.new(key, AES.MODE_CBC, iv)

ciphertext = cipher.encrypt(pad(plaintext, 16))

# Decrypt
decipher = AES.new(key, AES.MODE_CBC, iv)
decrypted = unpad(decipher.decrypt(ciphertext), 16)

Command line CBC:

openssl enc -aes-256-cbc -in file.txt -out file.enc -K <hex_key> -iv <hex_iv>

CTR Mode

Counter mode - turns block cipher into stream cipher

Encrypt counter values and XOR with plaintext

C1 = P1 ⊕ E(K, Counter + 1)
C2 = P2 ⊕ E(K, Counter + 2)
C3 = P3 ⊕ E(K, Counter + 3)

Advantages:

  • Parallelizable (encrypt/decrypt)
  • No padding needed
  • Random access (can decrypt any block)
  • Preprocessing possible

Disadvantages:

  • Never reuse nonce+key pair
  • No integrity protection
  • Bit-flipping attacks possible

Python CTR:

from Crypto.Cipher import AES
from Crypto.Util import Counter

key = get_random_bytes(16)
ctr = Counter.new(128)
cipher = AES.new(key, AES.MODE_CTR, counter=ctr)

ciphertext = cipher.encrypt(plaintext)  # No padding needed

# Decrypt
ctr2 = Counter.new(128)
decipher = AES.new(key, AES.MODE_CTR, counter=ctr2)
decrypted = decipher.decrypt(ciphertext)

GCM Mode

Galois/Counter Mode - authenticated encryption

Combines CTR mode encryption with GMAC authentication

Ciphertext = CTR_encrypt(Plaintext)
Tag = GMAC(Ciphertext || AAD)

Advantages:

  • Authenticated encryption (detects tampering)
  • Parallelizable
  • Hardware acceleration available
  • Industry standard (TLS 1.3 uses it)

Components:

  • Encryption: CTR mode
  • Authentication: GMAC (Galois Message Authentication Code)
  • Tag: 128-bit authentication tag

Python GCM:

cipher = AES.new(key, AES.MODE_GCM)
nonce = cipher.nonce

ciphertext, tag = cipher.encrypt_and_digest(plaintext)

# Decrypt and verify
decipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
decrypted = decipher.decrypt_and_verify(ciphertext, tag)

With additional authenticated data:

cipher = AES.new(key, AES.MODE_GCM)
cipher.update(b"header data")  # AAD not encrypted but authenticated
ciphertext, tag = cipher.encrypt_and_digest(plaintext)

Padding Schemes

Making plaintext fit block size

Block ciphers need input to be multiple of block size, padding fills the gap

PKCS#7 padding:

Pad with bytes indicating pad length

Plaintext: "HELLO" (5 bytes)
Block size: 8 bytes
Padding: 03 03 03
Result: "HELLO\x03\x03\x03"

Python PKCS#7:

def pkcs7_pad(data, block_size):
    pad_len = block_size - (len(data) % block_size)
    return data + bytes([pad_len] * pad_len)

def pkcs7_unpad(data):
    pad_len = data[-1]
    return data[:-pad_len]

padded = pkcs7_pad(b"HELLO", 8)
print(padded)  # b'HELLO\x03\x03\x03'

ISO/IEC 7816-4 padding:

Pad with 0x80 followed by zeros

Plaintext: "HELLO"
Padding: 80 00 00
Result: "HELLO\x80\x00\x00"

Zero padding:

Pad with zeros (ambiguous if plaintext ends with zero)

ANSI X.923:

Pad with zeros, last byte is pad length

Plaintext: "HELLO"
Padding: 00 00 03
Result: "HELLO\x00\x00\x03"

Using library padding:

from Crypto.Util.Padding import pad, unpad

padded = pad(b"HELLO", 16)
original = unpad(padded, 16)

This completes Part 2 (Topics 16-30: Symmetric Cryptography)


Public Key Cryptography

Two keys instead of one

Asymmetric crypto uses a key pair: public key (anyone can have it) and private key (only you have it)

Core concept:

  • Encrypt with public key , decrypt with private key
  • Sign with private key , verify with public key
  • Solves key distribution problem
  • Enables digital signatures

Mathematical foundation:

Based on hard mathematical problems: * RSA: factoring large numbers * Diffie-Hellman: discrete logarithm * ECC: elliptic curve discrete logarithm

Advantages:

  • No need to share secret keys
  • Digital signatures prove authenticity
  • Key exchange without prior communication
  • Non-repudiation (can't deny signing)

Disadvantages:

  • Slow (100-1000x slower than symmetric)
  • Larger key sizes (2048-4096 bits)
  • More complex mathematics
  • Vulnerable to quantum computers

Typical workflow:

  1. Generate key pair (public + private)
  2. Share public key freely
  3. Keep private key secret
  4. Others encrypt with your public key
  5. You decrypt with your private key

Python key generation:

from Crypto.PublicKey import RSA

key = RSA.generate(2048)
private_key = key.export_key()
public_key = key.publickey().export_key()

print(public_key.decode())

RSA Algorithm

The workhorse of public key crypto

RSA invented in 1977 by Rivest , Shamir , and Adleman , still widely used today despite being slow

How RSA works:

Based on the fact that multiplying primes is easy but factoring the product is hard

Easy: 61 × 53 = 3233
Hard: factor 3233 back to 61 and 53

With 2048-bit primes , factoring takes longer than the universe has existed

Key generation:

  1. Pick two large primes p and q
  2. Compute n = p × q (modulus)
  3. Compute φ(n) = (p-1)(q-1)
  4. Choose e such that gcd(e , φ(n)) = 1 (usually 65537)
  5. Compute d = e^-1 mod φ(n)
  6. Public key: (n , e)
  7. Private key: (n , d)

Encryption:

c = m^e mod n

Decryption:

m = c^d mod n

Why it works:

(m^e)^d = m^(ed) = m^(1 + kφ(n)) = m × (m^φ(n))^k ≡ m × 1^k ≡ m (mod n)

Python RSA implementation:

from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_OAEP

# Generate keys
key = RSA.generate(2048)
public_key = key.publickey()

# Encrypt
cipher = PKCS1_OAEP.new(public_key)
ciphertext = cipher.encrypt(b"Secret message")

# Decrypt
decipher = PKCS1_OAEP.new(key)
plaintext = decipher.decrypt(ciphertext)
print(plaintext)

RSA Key Generation

Creating secure RSA keys

Step-by-step process:

import random
from math import gcd

def generate_rsa_keys(bits=2048):
    # Step 1: Generate two large primes
    p = generate_prime(bits // 2)
    q = generate_prime(bits // 2)

    # Step 2: Compute modulus
    n = p * q

    # Step 3: Compute totient
    phi = (p - 1) * (q - 1)

    # Step 4: Choose public exponent
    e = 65537  # Common choice

    # Step 5: Compute private exponent
    d = mod_inverse(e , phi)

    return (n , e) , (n , d)

public , private = generate_rsa_keys()
print(f"Public key: {public}")
print(f"Private key: {private}")

Security considerations:

  • Use at least 2048 bits (3072 or 4096 for long-term)
  • Primes must be random and independent
  • p and q should be similar in size
  • Check that gcd(e , φ(n)) = 1
  • Destroy p , q , and φ(n) after key generation

Command to generate RSA keys:

# Generate 2048-bit private key
openssl genrsa -out private.pem 2048

# Extract public key
openssl rsa -in private.pem -pubout -out public.pem

# View key details
openssl rsa -in private.pem -text -noout

Key formats:

  • PEM: Base64-encoded with headers
  • DER: Binary format
  • PKCS#1: RSA-specific format
  • PKCS#8: Generic private key format

RSA Encryption

Encrypting messages with RSA

Textbook RSA (insecure):

def rsa_encrypt(m , e , n):
    return pow(m , e , n)

def rsa_decrypt(c , d , n):
    return pow(c , d , n)

# Example with small numbers
n , e = 3233 , 17
n , d = 3233 , 2753

message = 123
ciphertext = rsa_encrypt(message , e , n)
decrypted = rsa_decrypt(ciphertext , d , n)
print(f"Original: {message} , Decrypted: {decrypted}")

Why textbook RSA is broken:

  • Deterministic (same message = same ciphertext)
  • Malleable (attacker can modify ciphertext)
  • Small message attack (if m^e < n)
  • No integrity protection

PKCS#1 v1.5 padding:

Add random padding before encryption

EM = 0x00 || 0x02 || PS || 0x00 || M

Where:
- PS = random non-zero bytes
- Length of EM = key size

OAEP padding (better):

Optimal Asymmetric Encryption Padding

from Crypto.Cipher import PKCS1_OAEP
from Crypto.PublicKey import RSA

key = RSA.generate(2048)
cipher = PKCS1_OAEP.new(key.publickey())

ciphertext = cipher.encrypt(b"Message")
decipher = PKCS1_OAEP.new(key)
plaintext = decipher.decrypt(ciphertext)

Hybrid encryption:

Use RSA to encrypt symmetric key , then use symmetric key for data

from Crypto.Cipher import AES , PKCS1_OAEP
from Crypto.Random import get_random_bytes

# Generate random AES key
aes_key = get_random_bytes(32)

# Encrypt AES key with RSA
rsa_cipher = PKCS1_OAEP.new(public_key)
encrypted_key = rsa_cipher.encrypt(aes_key)

# Encrypt data with AES
aes_cipher = AES.new(aes_key , AES.MODE_GCM)
ciphertext , tag = aes_cipher.encrypt_and_digest(large_data)

RSA Signatures

Proving authenticity with RSA

How signing works:

  1. Hash the message
  2. Encrypt hash with private key (signature)
  3. Anyone can decrypt with public key
  4. Compare decrypted hash with actual hash

Python RSA signature:

from Crypto.Signature import pkcs1_15
from Crypto.Hash import SHA256
from Crypto.PublicKey import RSA

key = RSA.generate(2048)
message = b"Sign this message"

# Sign
h = SHA256.new(message)
signature = pkcs1_15.new(key).sign(h)

# Verify
try:
    h = SHA256.new(message)
    pkcs1_15.new(key.publickey()).verify(h , signature)
    print("Signature valid")
except:
    print("Signature invalid")

PSS padding (better than PKCS#1):

Probabilistic Signature Scheme

from Crypto.Signature import pss

# Sign with PSS
h = SHA256.new(message)
signature = pss.new(key).sign(h)

# Verify
try:
    h = SHA256.new(message)
    pss.new(key.publickey()).verify(h , signature)
    print("Valid")
except:
    print("Invalid")

Command to sign file:

# Sign
openssl dgst -sha256 -sign private.pem -out signature.bin file.txt

# Verify
openssl dgst -sha256 -verify public.pem -signature signature.bin file.txt

Diffie-Hellman

Key exchange without prior secrets

Allows two parties to agree on a shared secret over an insecure channel

How it works:

Public parameters: prime p , generator g

Alice:
1. Choose secret a
2. Compute A = g^a mod p
3. Send A to Bob

Bob:
1. Choose secret b
2. Compute B = g^b mod p
3. Send B to Alice

Shared secret:
Alice: s = B^a mod p = (g^b)^a mod p = g^(ab) mod p
Bob: s = A^b mod p = (g^a)^b mod p = g^(ab) mod p

Python implementation:

import random

def diffie_hellman():
    # Public parameters
    p = 23  # Prime (use large prime in practice)
    g = 5   # Generator

    # Alice's side
    a = random.randint(1 , p-1)
    A = pow(g , a , p)

    # Bob's side
    b = random.randint(1 , p-1)
    B = pow(g , b , p)

    # Compute shared secret
    secret_alice = pow(B , a , p)
    secret_bob = pow(A , b , p)

    print(f"Alice's secret: {secret_alice}")
    print(f"Bob's secret: {secret_bob}")
    assert secret_alice == secret_bob

diffie_hellman()

Security:

Based on discrete logarithm problem: given g , p , and g^x mod p , finding x is hard

Vulnerable to man-in-the-middle:

Attacker intercepts and replaces A and B , need authentication

Authenticated Diffie-Hellman:

Use certificates or pre-shared keys to authenticate parties


ECDH

Elliptic Curve Diffie-Hellman

Same concept as DH but using elliptic curves instead of modular arithmetic

Advantages over DH:

  • Smaller keys (256-bit ECC ≈ 3072-bit RSA)
  • Faster computation
  • Less bandwidth
  • Same security level

How ECDH works:

Public parameters: elliptic curve E , base point G

Alice:
1. Choose secret a
2. Compute A = a × G (point multiplication)
3. Send A to Bob

Bob:
1. Choose secret b
2. Compute B = b × G
3. Send B to Alice

Shared secret:
Alice: S = a × B = a × (b × G) = (ab) × G
Bob: S = b × A = b × (a × G) = (ab) × G

Python ECDH:

from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF

# Generate private keys
alice_private = ec.generate_private_key(ec.SECP256R1())
bob_private = ec.generate_private_key(ec.SECP256R1())

# Get public keys
alice_public = alice_private.public_key()
bob_public = bob_private.public_key()

# Compute shared secret
alice_shared = alice_private.exchange(ec.ECDH() , bob_public)
bob_shared = bob_private.exchange(ec.ECDH() , alice_public)

# Derive key from shared secret
kdf = HKDF(algorithm=hashes.SHA256() , length=32 , salt=None , info=b'')
alice_key = kdf.derive(alice_shared)
bob_key = kdf.derive(bob_shared)

print(alice_key == bob_key)  # True

Command to generate ECDH keys:

# Generate private key
openssl ecparam -name secp256k1 -genkey -noout -out ec_private.pem

# Extract public key
openssl ec -in ec_private.pem -pubout -out ec_public.pem

Elliptic Curves

The math behind modern crypto

Elliptic curves are algebraic curves defined by equation: y² = x³ + ax + b

Point addition:

Given two points P and Q on curve , P + Q is also on curve

Point doubling:

2P = P + P

Scalar multiplication:

nP = P + P + ... + P (n times)

Why curves work for crypto:

  • Point addition is easy
  • Scalar multiplication is easy
  • Discrete logarithm is hard (given P and Q = nP , finding n is hard)

Python point operations:

def point_add(P , Q , a , p):
    if P == (0 , 0):
        return Q
    if Q == (0 , 0):
        return P

    x1 , y1 = P
    x2 , y2 = Q

    if x1 == x2 and y1 == (-y2) % p:
        return (0 , 0)  # Point at infinity

    if P == Q:
        # Point doubling
        m = (3 * x1 * x1 + a) * pow(2 * y1 , -1 , p) % p
    else:
        # Point addition
        m = (y2 - y1) * pow(x2 - x1 , -1 , p) % p

    x3 = (m * m - x1 - x2) % p
    y3 = (m * (x1 - x3) - y1) % p

    return (x3 , y3)

# Example on curve y² = x³ + 7 (mod 17)
P = (5 , 1)
Q = (6 , 3)
R = point_add(P , Q , 0 , 17)
print(f"P + Q = {R}")

Curve25519

Modern elliptic curve for ECDH

Designed by Daniel Bernstein for high security and performance

Properties:

  • 128-bit security level
  • Fast constant-time implementation
  • Resistant to side-channel attacks
  • No known patents
  • Used in Signal , WireGuard , SSH

Curve equation:

y² = x³ + 486662x² + x (mod 2^255 - 19)

Python with Curve25519:

from cryptography.hazmat.primitives.asymmetric import x25519

# Generate keys
private_key = x25519.X25519PrivateKey.generate()
public_key = private_key.public_key()

# Peer's public key
peer_public = x25519.X25519PublicKey.from_public_bytes(peer_public_bytes)

# Compute shared secret
shared_secret = private_key.exchange(peer_public)
print(shared_secret.hex())

Command line:

# Generate Curve25519 key
openssl genpkey -algorithm X25519 -out x25519_private.pem

# Extract public key
openssl pkey -in x25519_private.pem -pubout -out x25519_public.pem

secp256k1

Bitcoin's elliptic curve

Used in Bitcoin , Ethereum , and other cryptocurrencies

Curve equation:

y² = x³ + 7 (mod p)

Where p = 2^256 - 2^32 - 977

Properties:

  • 128-bit security
  • Koblitz curve (special structure)
  • Efficient implementation
  • Well-studied

Python with secp256k1:

import ecdsa

# Generate key
sk = ecdsa.SigningKey.generate(curve=ecdsa.SECP256k1)
vk = sk.verifying_key

# Sign message
message = b"Transaction data"
signature = sk.sign(message)

# Verify
try:
    vk.verify(signature , message)
    print("Valid signature")
except:
    print("Invalid signature")

Bitcoin address generation:

import hashlib

def pubkey_to_address(pubkey):
    # SHA-256
    sha = hashlib.sha256(pubkey).digest()

    # RIPEMD-160
    ripe = hashlib.new('ripemd160' , sha).digest()

    # Add version byte
    versioned = b'\x00' + ripe

    # Double SHA-256 for checksum
    checksum = hashlib.sha256(hashlib.sha256(versioned).digest()).digest()[:4]

    # Base58 encode
    address = base58_encode(versioned + checksum)
    return address

ECDSA

Elliptic Curve Digital Signature Algorithm

Standard for digital signatures using elliptic curves

How ECDSA works:

Signing:
1. Hash message: h = H(m)
2. Choose random k
3. Compute R = k × G
4. r = R.x mod n
5. s = k^-1 (h + r × private_key) mod n
6. Signature = (r , s)

Verification:
1. Hash message: h = H(m)
2. Compute u1 = h × s^-1 mod n
3. Compute u2 = r × s^-1 mod n
4. Compute R' = u1 × G + u2 × public_key
5. Verify r == R'.x mod n

Python ECDSA:

from ecdsa import SigningKey , SECP256k1
import hashlib

# Generate key
sk = SigningKey.generate(curve=SECP256k1)
vk = sk.verifying_key

# Sign
message = b"Message to sign"
signature = sk.sign(message , hashfunc=hashlib.sha256)

# Verify
try:
    vk.verify(signature , message , hashfunc=hashlib.sha256)
    print("Valid")
except:
    print("Invalid")

Critical: never reuse k

If k is reused for two signatures , private key can be recovered

Sony PlayStation 3 hack:

Sony reused k in ECDSA signatures , hackers recovered private key and signed their own code


EdDSA

Edwards-curve Digital Signature Algorithm

Modern alternative to ECDSA using Edwards curves

Advantages over ECDSA:

  • Deterministic (no random k needed)
  • Faster signing and verification
  • Simpler implementation
  • Resistant to side-channel attacks
  • No known patents

Ed25519:

EdDSA using Curve25519

from cryptography.hazmat.primitives.asymmetric import ed25519

# Generate key
private_key = ed25519.Ed25519PrivateKey.generate()
public_key = private_key.public_key()

# Sign
message = b"Message"
signature = private_key.sign(message)

# Verify
try:
    public_key.verify(signature , message)
    print("Valid")
except:
    print("Invalid")

Command line:

# Generate Ed25519 key
ssh-keygen -t ed25519 -f ed25519_key

# Sign file
openssl pkeyutl -sign -inkey ed25519_key -out signature.bin -rawin -in file.txt

ElGamal

Alternative to RSA for encryption and signatures

Based on Diffie-Hellman problem

ElGamal encryption:

Key generation:
1. Choose prime p and generator g
2. Choose private key x
3. Compute public key y = g^x mod p

Encryption:
1. Choose random k
2. Compute c1 = g^k mod p
3. Compute c2 = m × y^k mod p
4. Ciphertext = (c1 , c2)

Decryption:
1. Compute s = c1^x mod p
2. Compute m = c2 × s^-1 mod p

Python ElGamal:

def elgamal_keygen(p , g):
    x = random.randint(1 , p-2)
    y = pow(g , x , p)
    return (p , g , y) , x

def elgamal_encrypt(m , public_key):
    p , g , y = public_key
    k = random.randint(1 , p-2)
    c1 = pow(g , k , p)
    c2 = (m * pow(y , k , p)) % p
    return c1 , c2

def elgamal_decrypt(c1 , c2 , x , p):
    s = pow(c1 , x , p)
    s_inv = pow(s , -1 , p)
    m = (c2 * s_inv) % p
    return m

Disadvantage:

Ciphertext is twice the size of plaintext


DSA

Digital Signature Algorithm

US government standard for digital signatures

How DSA works:

Key generation:
1. Choose prime p and q (q divides p-1)
2. Choose generator g of order q
3. Choose private key x
4. Compute public key y = g^x mod p

Signing:
1. Hash message: h = H(m)
2. Choose random k
3. Compute r = (g^k mod p) mod q
4. Compute s = k^-1 (h + x × r) mod q
5. Signature = (r , s)

Verification:
1. Hash message: h = H(m)
2. Compute w = s^-1 mod q
3. Compute u1 = h × w mod q
4. Compute u2 = r × w mod q
5. Compute v = (g^u1 × y^u2 mod p) mod q
6. Verify v == r

Python DSA:

from Crypto.PublicKey import DSA
from Crypto.Signature import DSS
from Crypto.Hash import SHA256

# Generate key
key = DSA.generate(2048)

# Sign
h = SHA256.new(b"Message")
signer = DSS.new(key , 'fips-186-3')
signature = signer.sign(h)

# Verify
verifier = DSS.new(key.publickey() , 'fips-186-3')
try:
    verifier.verify(h , signature)
    print("Valid")
except:
    print("Invalid")

Key Exchange Protocols

Securely establishing shared secrets

Static Diffie-Hellman:

Both parties have long-term DH keys

Ephemeral Diffie-Hellman:

Generate new keys for each session (forward secrecy)

Station-to-Station (STS):

Authenticated DH with signatures

Alice → Bob: g^a
Bob → Alice: g^b , Sign(g^b || g^a)
Alice → Bob: Sign(g^a || g^b)

MQV (Menezes-Qu-Vanstone):

Combines static and ephemeral keys

X3DH (Extended Triple Diffie-Hellman):

Used in Signal protocol

Alice has: Identity key IK_A , Signed prekey SPK_A , One-time prekey OPK_A
Bob has: Identity key IK_B , Ephemeral key EK_B

Shared secret = DH(IK_A , SPK_B) || DH(EK_A , IK_B) || DH(EK_A , SPK_B) || DH(EK_A , OPK_B)

Python X3DH simulation:

from cryptography.hazmat.primitives.asymmetric import x25519

# Alice's keys
alice_ik = x25519.X25519PrivateKey.generate()
alice_ek = x25519.X25519PrivateKey.generate()

# Bob's keys
bob_ik = x25519.X25519PrivateKey.generate()
bob_spk = x25519.X25519PrivateKey.generate()
bob_opk = x25519.X25519PrivateKey.generate()

# Compute shared secrets
dh1 = alice_ik.exchange(bob_spk.public_key())
dh2 = alice_ek.exchange(bob_ik.public_key())
dh3 = alice_ek.exchange(bob_spk.public_key())
dh4 = alice_ek.exchange(bob_opk.public_key())

shared_secret = dh1 + dh2 + dh3 + dh4
print(f"Shared secret: {shared_secret.hex()}")

This completes Part 3 (Topics 31-45: Asymmetric Cryptography)


Hash Functions

One-way mathematical meat grinders

Hash functions take input of any size and produce fixed-size output , you can't reverse it back to the original

Properties:

  • Deterministic: same input always gives same output
  • Fixed size: output always same length regardless of input
  • One-way: can't reverse hash back to input
  • Collision resistant: hard to find two inputs with same hash
  • Avalanche effect: tiny input change completely changes output

Common uses:

  • Password storage (hash passwords , never store plaintext)
  • Data integrity (verify files haven't been modified)
  • Digital signatures (sign hash instead of entire message)
  • Proof of work (Bitcoin mining)
  • Hash tables (data structure indexing)

Python hash example:

import hashlib

data = b"Hash this message"
hash_value = hashlib.sha256(data).hexdigest()
print(f"SHA-256: {hash_value}")

# Verify integrity
received_data = b"Hash this message"
if hashlib.sha256(received_data).hexdigest() == hash_value:
    print("Data intact")

Command line hashing:

# Hash file with SHA-256
sha256sum file.txt

# Hash string
echo -n "message" | sha256sum

# Verify checksum
sha256sum -c checksums.txt

MD5

Broken hash function still used for checksums

MD5 produces 128-bit hash , designed in 1991 , completely broken by 2004

Why MD5 is broken:

  • Collision attacks practical (can create two files with same hash)
  • Prefix collision attacks (can append data to create collision)
  • Used in real attacks (Flame malware forged Microsoft certificates)

Python MD5:

import hashlib

hash_md5 = hashlib.md5(b"data").hexdigest()
print(hash_md5)  # 32 hex characters (128 bits)

When MD5 is acceptable:

  • Non-security checksums (verify download integrity)
  • Hash table keys
  • Deduplication (if collision doesn't matter)

Never use MD5 for:

  • Password hashing
  • Digital signatures
  • Certificate verification
  • Any security-critical application

Command:

md5sum file.txt

SHA Family

Secure Hash Algorithm family

SHA-1:

  • 160-bit output
  • Broken (collision found in 2017)
  • Being phased out everywhere
  • Don't use for new systems

SHA-2 family:

  • SHA-224: 224-bit output
  • SHA-256: 256-bit output (most common)
  • SHA-384: 384-bit output
  • SHA-512: 512-bit output
  • All secure , widely used

SHA-3:

  • Winner of NIST competition (2012)
  • Different design from SHA-2 (Keccak)
  • Not replacing SHA-2 , just alternative
  • SHA3-256 , SHA3-512 variants

Python SHA-2:

import hashlib

# SHA-256
sha256 = hashlib.sha256(b"message").hexdigest()
print(f"SHA-256: {sha256}")

# SHA-512
sha512 = hashlib.sha512(b"message").hexdigest()
print(f"SHA-512: {sha512}")

Command line:

# SHA-256
echo -n "message" | sha256sum

# SHA-512
echo -n "message" | sha512sum

SHA-256

The standard cryptographic hash

256-bit output , part of SHA-2 family , used everywhere

How SHA-256 works:

  1. Pad message to multiple of 512 bits
  2. Initialize 8 hash values (constants)
  3. Process message in 512-bit chunks
  4. Each chunk goes through 64 rounds
  5. Final hash is concatenation of 8 values

Python implementation (simplified):

import hashlib

def sha256_hash(data):
    return hashlib.sha256(data).hexdigest()

# Hash string
print(sha256_hash(b"Hello"))

# Hash file
with open('file.txt' , 'rb') as f:
    file_hash = hashlib.sha256(f.read()).hexdigest()
    print(file_hash)

Incremental hashing:

hasher = hashlib.sha256()
hasher.update(b"part 1")
hasher.update(b"part 2")
hasher.update(b"part 3")
print(hasher.hexdigest())

Bitcoin mining:

Bitcoin uses double SHA-256: SHA256(SHA256(block_header))

def double_sha256(data):
    return hashlib.sha256(hashlib.sha256(data).digest()).digest()

SHA-3

The new generation hash function

Based on Keccak sponge construction , different from SHA-2

Sponge construction:

Input → Absorb phase → Squeeze phase → Output

State: 1600 bits
Rate: 1088 bits (for SHA3-256)
Capacity: 512 bits

Python SHA-3:

import hashlib

# SHA3-256
sha3_256 = hashlib.sha3_256(b"message").hexdigest()
print(sha3_256)

# SHA3-512
sha3_512 = hashlib.sha3_512(b"message").hexdigest()
print(sha3_512)

# SHAKE (extendable output)
shake128 = hashlib.shake_128(b"message").hexdigest(32)  # 32 bytes output
print(shake128)

SHAKE variants:

Variable-length output functions

# SHAKE128 with 256-bit output
output = hashlib.shake_128(b"data").digest(32)

# SHAKE256 with 512-bit output
output = hashlib.shake_256(b"data").digest(64)

BLAKE2

Fast cryptographic hash function

Faster than SHA-2 , as secure as SHA-3 , optimized for modern CPUs

Variants:

  • BLAKE2b: optimized for 64-bit platforms (up to 512-bit output)
  • BLAKE2s: optimized for 32-bit platforms (up to 256-bit output)

Python BLAKE2:

import hashlib

# BLAKE2b (512-bit)
blake2b = hashlib.blake2b(b"message").hexdigest()
print(blake2b)

# BLAKE2s (256-bit)
blake2s = hashlib.blake2s(b"message").hexdigest()
print(blake2s)

# Keyed hashing (MAC)
mac = hashlib.blake2b(b"message" , key=b"secret").hexdigest()
print(mac)

Personalization:

# Different hash for different purposes
hash1 = hashlib.blake2b(b"data" , person=b"app1").hexdigest()
hash2 = hashlib.blake2b(b"data" , person=b"app2").hexdigest()
# hash1 != hash2

Command line:

# BLAKE2b
b2sum file.txt

# BLAKE2s
b2sum -a blake2s file.txt

Message Authentication Codes

Proving message authenticity with shared secret

MAC is like a hash but with a secret key , proves message came from someone with the key

Properties:

  • Requires secret key
  • Verifies authenticity and integrity
  • Symmetric (same key for create and verify)
  • Deterministic (same message + key = same MAC)

Difference from signatures:

  • MAC: symmetric (shared secret)
  • Signature: asymmetric (public/private keys)
  • MAC: faster
  • Signature: provides non-repudiation

Common MAC algorithms:

  • HMAC: hash-based MAC
  • CMAC: cipher-based MAC
  • Poly1305: modern MAC
  • GMAC: used in GCM mode

HMAC

Hash-based Message Authentication Code

Combines hash function with secret key

HMAC construction:

HMAC(K , m) = H((K ⊕ opad) || H((K ⊕ ipad) || m))

Where:
- K = secret key
- m = message
- H = hash function
- opad = 0x5c repeated
- ipad = 0x36 repeated

Python HMAC:

import hmac
import hashlib

key = b"secret_key"
message = b"message to authenticate"

# Create HMAC
mac = hmac.new(key , message , hashlib.sha256).hexdigest()
print(f"HMAC: {mac}")

# Verify HMAC
received_mac = mac
if hmac.compare_digest(mac , received_mac):
    print("Authentic")
else:
    print("Tampered")

Timing-safe comparison:

Always use hmac.compare_digest() to prevent timing attacks

# Bad (timing attack vulnerable)
if mac == received_mac:
    pass

# Good (constant time)
if hmac.compare_digest(mac , received_mac):
    pass

Command line:

# Generate HMAC
echo -n "message" | openssl dgst -sha256 -hmac "secret_key"

Poly1305

Fast one-time MAC

Designed by Daniel Bernstein , used with ChaCha20

Properties:

  • Extremely fast (software optimized)
  • 128-bit security
  • One-time use (never reuse key)
  • Used in ChaCha20-Poly1305

Python Poly1305:

from Crypto.Cipher import ChaCha20_Poly1305

key = get_random_bytes(32)
cipher = ChaCha20_Poly1305.new(key=key)
nonce = cipher.nonce

ciphertext , tag = cipher.encrypt_and_digest(b"message")

# Verify and decrypt
decipher = ChaCha20_Poly1305.new(key=key , nonce=nonce)
plaintext = decipher.decrypt_and_verify(ciphertext , tag)

Why one-time:

Reusing key allows attacker to forge MACs


SipHash

Fast short-input MAC

Designed for hash table protection against DoS attacks

Use cases:

  • Hash table keys
  • Bloom filters
  • Network packet authentication
  • Short message authentication

Python SipHash:

import siphash

key = b"0123456789ABCDEF"  # 16 bytes
message = b"data"

hash_value = siphash.SipHash_2_4(key , message).hash()
print(hash_value)

Not for:

Long messages (use HMAC instead) Cryptographic signatures (use HMAC-SHA256)


Authenticated Encryption

Encryption + authentication in one operation

Provides both confidentiality and integrity

Why needed:

Encryption alone doesn't prevent tampering , attacker can modify ciphertext

AEAD (Authenticated Encryption with Associated Data):

  • Encrypts plaintext
  • Authenticates ciphertext
  • Authenticates additional data (not encrypted)

Common AEAD modes:

  • AES-GCM: most popular
  • ChaCha20-Poly1305: modern alternative
  • AES-CCM: used in WiFi
  • AES-OCB: fastest but patented

Python AES-GCM:

from Crypto.Cipher import AES
from Crypto.Random import get_random_bytes

key = get_random_bytes(32)
cipher = AES.new(key , AES.MODE_GCM)
nonce = cipher.nonce

# Additional authenticated data (not encrypted)
header = b"packet header"
cipher.update(header)

# Encrypt and authenticate
ciphertext , tag = cipher.encrypt_and_digest(b"secret data")

# Decrypt and verify
decipher = AES.new(key , AES.MODE_GCM , nonce=nonce)
decipher.update(header)
plaintext = decipher.decrypt_and_verify(ciphertext , tag)

ChaCha20-Poly1305:

from Crypto.Cipher import ChaCha20_Poly1305

cipher = ChaCha20_Poly1305.new(key=key)
cipher.update(b"header")
ciphertext , tag = cipher.encrypt_and_digest(b"message")

Key Derivation Functions

Turning passwords into keys

KDFs convert weak passwords into strong cryptographic keys

Why needed:

  • Passwords are weak (low entropy)
  • Need fixed-length keys
  • Want to slow down brute force
  • Add salt to prevent rainbow tables

Properties of good KDF:

  • Slow (intentionally)
  • Memory-hard (resist GPU attacks)
  • Configurable work factor
  • Salt support

Common KDFs:

  • PBKDF2: standard , widely supported
  • bcrypt: popular for passwords
  • scrypt: memory-hard
  • Argon2: modern , winner of PHC

PBKDF2

Password-Based Key Derivation Function 2

Standard KDF , widely supported , configurable iterations

How it works:

DK = PBKDF2(password , salt , iterations , key_length)

Internally:
- Repeatedly applies HMAC
- Each iteration makes brute force harder

Python PBKDF2:

import hashlib
from os import urandom

password = b"user_password"
salt = urandom(16)
iterations = 100000

key = hashlib.pbkdf2_hmac('sha256' , password , salt , iterations)
print(f"Derived key: {key.hex()}")

Storing passwords:

def hash_password(password):
    salt = urandom(16)
    key = hashlib.pbkdf2_hmac('sha256' , password.encode() , salt , 100000)
    return salt + key

def verify_password(password , stored):
    salt = stored[:16]
    stored_key = stored[16:]
    key = hashlib.pbkdf2_hmac('sha256' , password.encode() , salt , 100000)
    return key == stored_key

Command line:

openssl enc -pbkdf2 -iter 100000 -aes-256-cbc -in file.txt -out file.enc

bcrypt

Password hashing designed to be slow

Built-in salt , adaptive work factor , resistant to GPU attacks

Python bcrypt:

import bcrypt

password = b"user_password"

# Hash password
hashed = bcrypt.hashpw(password , bcrypt.gensalt(rounds=12))
print(hashed)

# Verify password
if bcrypt.checkpw(password , hashed):
    print("Correct password")
else:
    print("Wrong password")

Work factor:

# Faster (2^10 iterations)
bcrypt.gensalt(rounds=10)

# Slower (2^14 iterations)
bcrypt.gensalt(rounds=14)

Stored format:

$2b$12$R9h/cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ss7KIUgO2t0jWMUW
 │  │  │                     │
 │  │  └─ salt               └─ hash
 │  └─ cost factor (2^12)
 └─ algorithm version

scrypt

Memory-hard key derivation function

Designed to resist GPU and ASIC attacks by requiring lots of memory

Parameters:

  • N: CPU/memory cost (power of 2)
  • r: block size
  • p: parallelization factor

Python scrypt:

import hashlib

password = b"password"
salt = urandom(16)

# N=2^14 , r=8 , p=1
key = hashlib.scrypt(password , salt=salt , n=2**14 , r=8 , p=1 , dklen=32)
print(key.hex())

Memory usage:

Memory = 128 * N * r bytes

Example: N=2^14 , r=8
Memory = 128 * 16384 * 8 = 16 MB

Tuning:

# Low security (fast)
hashlib.scrypt(password , salt=salt , n=2**10 , r=8 , p=1)

# High security (slow)
hashlib.scrypt(password , salt=salt , n=2**18 , r=8 , p=1)

Argon2

Modern password hashing (PHC winner)

Designed in 2015 , resistant to GPU , FPGA , and ASIC attacks

Variants:

  • Argon2d: data-dependent (faster , vulnerable to side-channels)
  • Argon2i: data-independent (slower , side-channel resistant)
  • Argon2id: hybrid (recommended)

Python Argon2:

from argon2 import PasswordHasher

ph = PasswordHasher()

# Hash password
hash_value = ph.hash("password")
print(hash_value)

# Verify password
try:
    ph.verify(hash_value , "password")
    print("Correct")
except:
    print("Wrong")

Custom parameters:

from argon2 import PasswordHasher

ph = PasswordHasher(
    time_cost=3 ,      # iterations
    memory_cost=65536 , # 64 MB
    parallelism=4 ,     # threads
    hash_len=32 ,       # output length
    salt_len=16         # salt length
)

Recommended settings (2024):

# Interactive (login)
time_cost=2 , memory_cost=65536 , parallelism=4

# Moderate
time_cost=3 , memory_cost=262144 , parallelism=4

# Sensitive (encryption keys)
time_cost=4 , memory_cost=1048576 , parallelism=4

Digital Signatures

Cryptographic proof of authenticity

Digital signature proves message came from specific person and hasn't been modified

How signatures work:

Signing:
1. Hash message
2. Encrypt hash with private key
3. Signature = encrypted hash

Verification:
1. Hash message
2. Decrypt signature with public key
3. Compare hashes

Properties:

  • Authentication: proves who signed
  • Integrity: detects modifications
  • Non-repudiation: can't deny signing

Python RSA signature:

from Crypto.Signature import pkcs1_15
from Crypto.Hash import SHA256
from Crypto.PublicKey import RSA

key = RSA.generate(2048)

# Sign
message = b"Contract terms"
h = SHA256.new(message)
signature = pkcs1_15.new(key).sign(h)

# Verify
try:
    pkcs1_15.new(key.publickey()).verify(h , signature)
    print("Valid signature")
except:
    print("Invalid signature")

ECDSA signature:

from ecdsa import SigningKey , SECP256k1

sk = SigningKey.generate(curve=SECP256k1)
vk = sk.verifying_key

signature = sk.sign(b"message")
vk.verify(signature , b"message")

Certificate Authorities

Trust infrastructure for public keys

CAs issue digital certificates binding public keys to identities

Certificate structure:

Certificate:
- Subject: who owns the key
- Public key
- Issuer: who signed it
- Validity period
- Signature

X.509 certificate:

from cryptography import x509
from cryptography.hazmat.primitives import hashes

# Load certificate
with open('cert.pem' , 'rb') as f:
    cert = x509.load_pem_x509_certificate(f.read())

# Extract info
subject = cert.subject
issuer = cert.issuer
public_key = cert.public_key()
not_before = cert.not_valid_before
not_after = cert.not_valid_after

Command to view certificate:

# View certificate details
openssl x509 -in cert.pem -text -noout

# Verify certificate chain
openssl verify -CAfile ca.pem cert.pem

Self-signed certificate:

# Generate private key
openssl genrsa -out key.pem 2048

# Create self-signed certificate
openssl req -new -x509 -key key.pem -out cert.pem -days 365

This completes Part 4 (Topics 46-60: Hash Functions and MACs)


TLS Protocol

Transport Layer Security - securing the internet

TLS encrypts communication between client and server , used in HTTPS , email , VPNs

TLS handshake:

Client → Server: ClientHello (supported ciphers , random)
Server → Client: ServerHello (chosen cipher , random)
Server → Client: Certificate (public key)
Server → Client: ServerHelloDone

Client → Server: ClientKeyExchange (encrypted pre-master secret)
Client → Server: ChangeCipherSpec
Client → Server: Finished (encrypted)

Server → Client: ChangeCipherSpec
Server → Client: Finished (encrypted)

[Encrypted application data]

TLS 1.3 improvements:

  • Faster handshake (1-RTT instead of 2-RTT)
  • Forward secrecy mandatory
  • Removed weak ciphers
  • Encrypted handshake messages

Python TLS client:

import ssl
import socket

context = ssl.create_default_context()

with socket.create_connection(('google.com' , 443)) as sock:
    with context.wrap_socket(sock , server_hostname='google.com') as ssock:
        print(ssock.version())
        print(ssock.cipher())
        ssock.sendall(b'GET / HTTP/1.1\r\nHost: google.com\r\n\r\n')
        print(ssock.recv(1024))

Command to test TLS:

# Check TLS version and cipher
openssl s_client -connect google.com:443 -tls1_3

# View certificate
openssl s_client -connect google.com:443 -showcerts

SSH Protocol

Secure Shell for remote access

SSH provides encrypted terminal access and file transfer

SSH connection:

1. TCP connection on port 22
2. Protocol version exchange
3. Key exchange (DH or ECDH)
4. Server authentication (host key)
5. User authentication (password or public key)
6. Encrypted session

Key exchange algorithms:

  • diffie-hellman-group14-sha256
  • ecdh-sha2-nistp256
  • curve25519-sha256

Python SSH client:

import paramiko

client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())

client.connect('hostname' , username='user' , key_filename='private_key')

stdin , stdout , stderr = client.exec_command('ls -la')
print(stdout.read().decode())

client.close()

Generate SSH keys:

# Ed25519 (recommended)
ssh-keygen -t ed25519 -C "your_email@example.com"

# RSA 4096-bit
ssh-keygen -t rsa -b 4096 -C "your_email@example.com"

# ECDSA
ssh-keygen -t ecdsa -b 521

SSH config hardening:

# /etc/ssh/sshd_config
Protocol 2
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
KexAlgorithms curve25519-sha256
Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com
MACs hmac-sha2-512-etm@openssh.com

PGP and GPG

Pretty Good Privacy for email encryption

PGP encrypts emails and files using public key cryptography

GPG key generation:

# Generate key pair
gpg --full-generate-key

# List keys
gpg --list-keys

# Export public key
gpg --armor --export user@example.com > public.key

# Export private key
gpg --armor --export-secret-keys user@example.com > private.key

Encrypt and decrypt:

# Encrypt file
gpg --encrypt --recipient user@example.com file.txt

# Decrypt file
gpg --decrypt file.txt.gpg > file.txt

# Sign file
gpg --sign file.txt

# Verify signature
gpg --verify file.txt.gpg

Python GPG:

import gnupg

gpg = gnupg.GPG()

# Encrypt
encrypted = gpg.encrypt('message' , 'recipient@example.com')
print(encrypted)

# Decrypt
decrypted = gpg.decrypt(str(encrypted))
print(decrypted)

Post-Quantum Cryptography

Preparing for quantum computers

Quantum computers will break RSA , ECC , and Diffie-Hellman , need quantum-resistant algorithms

Quantum threats:

  • Shor's algorithm: breaks RSA and ECC in polynomial time
  • Grover's algorithm: weakens symmetric crypto (double key size)

Post-quantum algorithms:

  • Lattice-based: CRYSTALS-Kyber (key exchange) , CRYSTALS-Dilithium (signatures)
  • Hash-based: SPHINCS+ (signatures)
  • Code-based: Classic McEliece (encryption)
  • Multivariate: Rainbow (signatures)

NIST PQC winners (2022):

  • CRYSTALS-Kyber: key encapsulation
  • CRYSTALS-Dilithium: digital signatures
  • SPHINCS+: stateless signatures
  • FALCON: compact signatures

Python with Kyber:

from pqcrypto.kem.kyber512 import generate_keypair , encrypt , decrypt

# Generate keys
public_key , secret_key = generate_keypair()

# Encapsulate (create shared secret)
ciphertext , shared_secret = encrypt(public_key)

# Decapsulate (recover shared secret)
recovered_secret = decrypt(secret_key , ciphertext)

assert shared_secret == recovered_secret

Migration strategy:

  • Hybrid approach: use both classical and PQC
  • Increase symmetric key sizes (AES-128 → AES-256)
  • Monitor NIST standardization
  • Test PQC implementations

Zero Knowledge Proofs

Prove knowledge without revealing it

ZKP allows proving statement is true without revealing why

Example: password proof

Prove you know password without sending it

zk-SNARKs:

Zero-Knowledge Succinct Non-Interactive Argument of Knowledge

Used in Zcash cryptocurrency for private transactions

Applications:

  • Anonymous credentials
  • Private transactions (cryptocurrencies)
  • Authentication without passwords
  • Verifiable computation

Homomorphic Encryption

Computing on encrypted data

Perform operations on ciphertext that produce encrypted results

Types:

  • Partially homomorphic: one operation (RSA multiplication , Paillier addition)
  • Somewhat homomorphic: limited operations
  • Fully homomorphic: unlimited operations

RSA multiplicative homomorphism:

from Crypto.PublicKey import RSA

key = RSA.generate(2048)
e , n = key.e , key.n

# Encrypt two numbers
m1 , m2 = 5 , 7
c1 = pow(m1 , e , n)
c2 = pow(m2 , e , n)

# Multiply ciphertexts
c_product = (c1 * c2) % n

# Decrypt result
m_product = pow(c_product , key.d , n)
print(m_product)  # 35 = 5 * 7

Applications:

  • Cloud computing on encrypted data
  • Private database queries
  • Secure voting systems
  • Medical data analysis

Cryptographic Best Practices

Don't roll your own crypto

Key management:

  • Generate keys with cryptographically secure RNG
  • Store keys in hardware security modules (HSM)
  • Rotate keys periodically
  • Destroy keys securely (overwrite memory)
  • Never hardcode keys in source code

Algorithm selection:

  • Symmetric: AES-256-GCM or ChaCha20-Poly1305
  • Asymmetric: RSA-4096 or Ed25519
  • Hashing: SHA-256 or BLAKE2
  • Passwords: Argon2id or bcrypt
  • Key exchange: ECDH with Curve25519

Common mistakes:

  • Using ECB mode
  • Reusing nonces
  • Not authenticating ciphertext
  • Weak random number generation
  • Implementing crypto from scratch

Summary and Next Steps

You've covered 75 topics in cryptography

What you learned:

  • Classical ciphers (Caesar , Vigenère , transposition)
  • Mathematical foundations (modular arithmetic , primes , discrete log)
  • Symmetric crypto (AES , ChaCha20 , block cipher modes)
  • Asymmetric crypto (RSA , ECC , Diffie-Hellman)
  • Hash functions (SHA-256 , SHA-3 , BLAKE2)
  • MACs and authenticated encryption
  • Key derivation (PBKDF2 , bcrypt , Argon2)
  • Digital signatures and certificates
  • Advanced topics (TLS , post-quantum , ZKP , homomorphic)

Linux crypto commands reference:

# Generate random data
head -c 32 /dev/urandom | base64

# Hash file
sha256sum file.txt

# Encrypt with AES
openssl enc -aes-256-gcm -pbkdf2 -in file.txt -out file.enc

# Generate SSH key
ssh-keygen -t ed25519

# Test TLS connection
openssl s_client -connect example.com:443

# View certificate
openssl x509 -in cert.pem -text -noout

# Generate self-signed cert
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes