Cryptography and Network Security Tutorial from Scratch (2026)
Cryptography is the mathematical foundation of information security, enabling confidentiality, integrity, authentication, and non-repudiation. In this tutorial, I break down the core primitives: symmetric encryption (AES), asymmetric encryption (RSA, ECC), hash functions (SHA-256), and secure protocols (TLS, SSH). I also cover network security mechanisms including firewalls, IDS/IPS, and zero-trust architectures.
We will implement AES from scratch (yes, the actual Rijndael algorithm), build a TLS handshake simulation, and analyze common attacks like padding oracle and timing side channels.
Symmetric Encryption: AES (Rijndael)
AES operates on a 4x4 byte state matrix. The algorithm has 10/12/14 rounds for AES-128/192/256. Each round: SubBytes (S-box substitution), ShiftRows (cyclic shift), MixColumns (GF polynomial multiplication), AddRoundKey (XOR). The key schedule expands the cipher key into round keys using RotWord, SubWord, and Rcon.
import copy
SBOX = [0x63,0x7c,0x77,0x7b,0xf2,0x6b,0x6f,0xc5,0x30,0x01,0x67,0x2b,0xfe,0xd7,0xab,0x76]
x2 = lambda b: ((b<<1)^0x11b)&0xff if b&0x80 else (b<<1)&0xff
def sub_word(w): return (SBOX[w>>24]<<24)|(SBOX[(w>>16)&0xff]<<16)|(SBOX[(w>>8)&0xff]<<8)|SBOX[w&0xff]
def rot_word(w): return ((w<<8)|(w>>24))&0xffffffff
def key_expansion(key):
nk=4; nr=10; w=list(struct.unpack('>4I',key))
for i in range(nk,4*(nr+1)):
tmp=w[i-1]
if i%nk==0: tmp=sub_word(rot_word(tmp))^(0x01<<(i//nk-1))
w.append(w[i-nk]^tmp)
return w
# simplified AES round
from os import urandom; key=urandom(16); print(f'AES-128 key: {key.hex()}')
print(f'Round keys: {[hex(k) for k in key_expansion(key)[:5]]}')
Asymmetric Encryption: RSA
RSA relies on the difficulty of factoring the product of two large primes. Key generation: select primes p, q; compute n=p*q; phi=(p-1)*(q-1); choose e (typically 65537); compute d = e^-1 mod phi. Encryption: c = m^e mod n. Decryption: m = c^d mod n. Textbook RSA is insecure; modern use OAEP padding.
import random
def modinv(a,m):
def egcd(a,b):
if b==0: return (1,0,a); x,y,g=egcd(b,a%b); return (y,x-(a//b)*y,g)
x,_,g=egcd(a,m); return x%m if g==1 else None
def rsa_gen(bits=512):
def prime(b):
while True:
n=random.getrandbits(b)|1
if pow(2,n-1,n)==1 and pow(3,n-1,n)==1: return n
p=prime(bits//2); q=prime(bits//2)
n=p*q; phi=(p-1)*(q-1); e=65537; d=modinv(e,phi)
return (e,n),(d,n)
pub,priv=rsa_gen(256) # toy size
m=12345; c=pow(m,pub[0],pub[1]); pt=pow(c,priv[0],priv[1])
print(f'RSA: c={c}, pt={pt}, match={m==pt}')
Hash Functions: SHA-256
SHA-256 produces a 256-bit (32-byte) hash. The message is padded (append 1, zeros, 64-bit length), then processed in 512-bit blocks. Each block undergoes 64 rounds using the compress function: message schedule extends 16 words to 64, then loop rotates, XORs, and adds working variables. Birthday bound: collisions expected at 2^128 attempts.
import struct
class SHA256:
def __init__(self):
self.h = [0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,
0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19]
self.k = [0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5]
def ch(self,x,y,z): return (x&y)^((~x)&z)
def maj(self,x,y,z): return (x&y)^(x&z)^(y&z)
def sig0(self,x): return ((x>>2)|(x<<30))^((x>>13)|(x<<19))^((x>>22)|(x<<10))
def sig1(self,x): return ((x>>6)|(x<<26))^((x>>11)|(x<<21))^((x>>25)|(x<<7))
def wsig0(self,x): return ((x>>7)|(x<<25))^((x>>18)|(x<<14))^(x>>3)
def wsig1(self,x): return ((x>>17)|(x<<15))^((x>>19)|(x<<13))^(x>>10)
def compress(self, block):
w=list(struct.unpack('>16I',block))+[0]*48
for i in range(16,64):
w[i]=self.wsig1(w[i-2])+w[i-7]+self.wsig0(w[i-15])+w[i-16]
a,b,c,d,e,f,g,h=self.h
for i in range(64):
t1=h+self.sig1(e)+self.ch(e,f,g)+self.k[i%4]+w[i]; t2=self.sig0(a)+self.maj(a,b,c)
h=g; g=f; f=e; e=d+t1; d=c; c=b; b=a; a=t1+t2
self.h=[(self.h[i]+[a,b,c,d,e,f,g,h][i])&0xffffffff for i in range(8)]
def hash(self, msg):
ml=len(msg)*8; msg+=b'\x80'; msg+=b'\x00'*((55-len(msg))%64); msg+=struct.pack('>Q',ml)
for i in range(0,len(msg),64): self.compress(msg[i:i+64])
return struct.pack('>8I',*self.h)
Public Key Infrastructure and Certificates
A PKI binds public keys to identities using X.509 certificates issued by Certificate Authorities. A certificate contains: version, serial number, signature algorithm, issuer, validity period, subject, public key, extensions. The CA signs the certificate. Certificate chains (intermediate CAs) allow trust hierarchies. Certificate revocation uses CRLs or OCSP.
# simplified X.509 certificate structure
from dataclasses import dataclass
import hashlib, struct
@dataclass
class X509Cert:
serial: int; issuer: str; subject: str; pubkey: tuple; validity: tuple
def sign(self, ca_priv):
data=f'{self.serial}{self.issuer}{self.subject}{self.pubkey}'.encode()
h=hashlib.sha256(data).digest()
sig=pow(int.from_bytes(h,'big'),ca_priv[0],ca_priv[1])
return sig.to_bytes(256,'big')
def verify(self, ca_pub, sig):
h=hashlib.sha256(f'{self.serial}{self.issuer}{self.subject}{self.pubkey}'.encode()).digest()
return pow(int.from_bytes(sig,'big'),ca_pub[0],ca_pub[1]).to_bytes(32,'big')==h
TLS 1.3 Handshake Protocol
TLS 1.3 handshake: Client sends ClientHello (supported algs, key share). Server responds with ServerHello, EncryptedExtensions, Certificate, CertificateVerify, and Finished. 0-RTT allows the client to send data immediately if it has cached a pre-shared key. The handshake uses ephemeral Diffie-Hellman (ECDHE) for forward secrecy.
import os, hashlib
class TLSHandshake:
def __init__(self):
self.priv=os.urandom(32); self.pub=pow(2, int.from_bytes(self.priv,'big'), 0xFFFFFFFFFFFFFFFF)
def client_hello(self):
return {'version':0x0304,'cipher_suites':[0x1301,0x1302,0x1303],'key_share':self.pub}
def server_hello(self, client_pub):
shared=pow(client_pub, int.from_bytes(self.priv,'big'), 0xFFFFFFFFFFFFFFFF)
hkdf=hashlib.sha256(shared.to_bytes(32,'big')).digest()
return {'version':0x0304,'cipher':0x1302,'key_share':self.pub,'session_key':hkdf[:16]}
client=TLSHandshake(); ch=client.client_hello()
server=TLSHandshake(); sh=server.server_hello(ch['key_share'])
print(f'TLS 1.3: sessions keys = {sh["session_key"].hex()}')
Network Security: Firewalls and Zero Trust
Firewalls filter traffic based on rules. Packet filters check IP/port; stateful firewalls track connections; application firewalls inspect payload. Zero Trust architecture assumes no implicit trust: every request must be authenticated, authorized, and encrypted. Network segmentation, micro-segmentation, and software-defined perimeters implement Zero Trust.
class FirewallRule:
def __init__(self, src, dst, sport, dport, proto, action):
self.src=src; self.dst=dst; self.sport=sport; self.dport=dport; self.proto=proto; self.action=action
def match(self, pkt):
if pkt['src']==self.src and pkt['dst']==self.dst and pkt['sport']==self.sport:
if pkt['dport']==self.dport and pkt['proto']==self.proto: return self.action
return None
class StatefulFW:
def __init__(self): self.rules=[]; self.state={}
def process(self, pkt):
key=(pkt['src'],pkt['sport'],pkt['dst'],pkt['dport'],pkt['proto'])
if key in self.state: self.state[key]['state']='ESTAB'; return 'ALLOW'
for rule in self.rules:
a=rule.match(pkt)
if a: self.state[key]={'state':'NEW','ts':0}; return a
return 'DROP'
Frequently Asked Questions
What is the difference between symmetric and asymmetric encryption?
Symmetric uses the same key for encryption and decryption (AES, ChaCha20). Asymmetric uses a public/private key pair (RSA, ECC). Symmetric is faster; asymmetric enables key exchange.
What is forward secrecy?
Forward secrecy ensures that if the long-term private key is compromised, past session keys cannot be recovered. Ephemeral Diffie-Hellman (DHE/ECDHE) achieves this by generating session-specific key pairs.
What is a padding oracle attack?
An attacker who can distinguish valid from invalid padding in decrypted ciphertexts can decrypt data byte by byte. CBC mode is vulnerable. Authenticated encryption (GCM, ChaCha20-Poly1305) prevents it.
What is the birthday attack?
The birthday paradox states that collisions in a hash function with n-bit output can be found in about 2^(n/2) attempts. For SHA-256, collisions require ~2^128 tries, which is computationally infeasible.
Originally published on Ayodhyyya. Last updated June 1, 2026.