VHash is an educational, zero-dependency Python implementation of MD5, SHA-1 and SHA-256 — and this README is the tutorial that goes with it. By the end you should understand exactly how these algorithms work, well enough to implement them yourself. The source code is the reference; this document is the guided tour.
SECURITY WARNING: This project is built strictly for educational purposes. MD5 and SHA-1 are cryptographically broken and must never be used in production. For production security, always use Python's built-in
hashlibmodule.
- Quick start
- Part 0 — What a hash function actually promises
- Part 1 — The shared machinery
- Part 2 — MD5, step by step
- Part 3 — SHA-1, step by step
- Part 4 — SHA-256, step by step
- Part 5 — Side-by-side comparison
- Part 6 — How MD5 and SHA-1 died
- Verify everything
- Production security note
No installation, no dependencies — clone and run with Python 3.6+:
git clone https://github.com/gabrielevierti/vhash.git
cd vhash
python vhash.py myfile.txt # hash a file (default: MD5)
python vhash.py myfile.txt SHA256 # hash a file with SHA-256
python vhash.py --string "Hello World" SHA1 # hash a string
python vhash.py --test # validate against official RFC/FIPS vectors
python vhash.py --explain # in-terminal walkthroughExit codes: 0 on success, 1 on file/IO errors, 2 on an unknown algorithm. Errors go to stderr. The API mirrors hashlib — update() / hexdigest() — and streams files in fixed-size chunks, so nothing is ever loaded fully into memory.
A cryptographic hash function takes an input of any length and produces a fixed-size digest. That alone is easy; a checksum does it. What makes a hash function cryptographic is three security properties, in increasing order of difficulty for the designer:
- Preimage resistance — given a digest
h, it must be infeasible to find any messagemwithhash(m) = h. (You can't run it backwards.) - Second-preimage resistance — given a message
m1, it must be infeasible to find a differentm2with the same digest. (You can't forge a substitute for a specific document.) - Collision resistance — it must be infeasible to find any two messages with the same digest, even ones you chose freely. (This is the hardest promise, and the first to fall — the birthday paradox means a brute-force collision costs only 2^(n/2) attempts for an n-bit digest, and real attacks do far better.)
When we say MD5 and SHA-1 are "broken", we mean specifically that collision resistance has collapsed — preimage resistance is still holding for both, which is why you'll still see MD5 used (legitimately) as a fast non-security checksum, and why its presence in a password database is (illegitimately) catastrophic for entirely different reasons.
One more property matters for the design: the avalanche effect — flipping one input bit should flip about half the output bits. Everything you'll see below (the rotations, the mixing functions, the absurd-looking constants) exists to produce it.
MD5, SHA-1 and SHA-256 are all members of the Merkle–Damgård family. They share one skeleton and differ only in register count, constants, and the compression function. Learn the skeleton once and each algorithm becomes a parameter set.
Input Message
│
▼
[ Padding ] ─── extend the message to a multiple of 512 bits
│
▼
[ Initialize ] ─ load fixed initial values (IV) into the state registers
│
▼
[ For each 512-bit block ]
│ state = state + compress(state, block)
▼
[ Final State ] ─ concatenate the registers → digest
The crucial idea: the state (128–256 bits, depending on the algorithm) is a running fingerprint. Each block is folded into it through a compression function — a fixed sequence of bitwise operations designed to be easy to compute and hopeless to invert. The + after compress matters too: the compression output is added to the previous state (the Davies–Meyer feed-forward), which is what makes the process one-way rather than a reversible scramble.
All three algorithms pad identically in structure:
- Append a single
1bit — in byte terms,0x80. - Append
0x00bytes until the message length is 56 mod 64 bytes (i.e. 8 bytes short of a full block). - Append the original message length in bits as a 64-bit integer, filling the block to exactly 64 bytes.
Worked example — the 3-byte message "abc" (24 bits):
61 62 63 80 00 00 00 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 [ length = 24, as 64-bit int ]
abc → 0x80 marker → 52 zero bytes → 8-byte length. One block, ready to compress. Why encode the length at all? It prevents trivial collisions between messages that differ only in trailing zeros, and it's part of what makes the construction provably collision-resistant if the compression function is (Merkle and Damgård proved exactly that — hence the name).
In Python:
def pad(message: bytes, byteorder: str) -> bytes:
bit_len = len(message) * 8
message += b'\x80'
message += b'\x00' * ((56 - len(message) % 64) % 64)
message += bit_len.to_bytes(8, byteorder) # 'little' for MD5, 'big' for SHA
return messageThat byteorder parameter is the single most common source of "my digest is wrong" bugs:
- MD5 is little-endian — the length field and every 32-bit word of the message block are read least-significant-byte first, and the final digest is emitted the same way.
- SHA-1 and SHA-256 are big-endian — everywhere.
Get this wrong and your implementation will be internally consistent, pass no test vector, and give you no hint why. (Ask me how I know.)
These algorithms were designed for 32-bit registers that overflow silently: 0xFFFFFFFF + 1 = 0. Python integers are arbitrary-precision and never overflow, so every addition must be masked back into 32 bits — and Python has no rotate operator, so rotation is composed from shifts:
MASK = 0xFFFFFFFF
def add(*values): # modular 2^32 addition
return sum(values) & MASK
def rotl(x, n): # rotate left (MD5, SHA-1)
return ((x << n) | (x >> (32 - n))) & MASK
def rotr(x, n): # rotate right (SHA-256)
return ((x >> n) | (x << (32 - n))) & MASKWhy rotation instead of shifting? A shift discards bits; a rotation preserves all of them while moving them to new positions — lossless mixing. Combined with XOR/AND (which are linear and non-linear over different structures) and modular addition (whose carries propagate information between bit positions), you get the diffusion these designs live on. Every algorithm below is just these three primitives, arranged with malice aforethought.
Ronald Rivest, 1992 (RFC 1321). Digest: 128 bits. State: four 32-bit registers.
A, B, C, D = 0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476Look at those in little-endian byte order and the trick reveals itself: 01 23 45 67, 89 AB CD EF, FE DC BA 98, 76 54 32 10. Counting up, counting down. These are nothing-up-my-sleeve numbers — values chosen to be so obviously patterned that nobody can suspect a hidden backdoor was engineered into them.
MD5 uses 64 constants, one per step, derived from the sine function:
import math
T = [int(abs(math.sin(i + 1)) * 2**32) & 0xFFFFFFFF for i in range(64)]
# T[0] = 0xD76AA478, T[1] = 0xE8C7B756, ...Again nothing-up-my-sleeve: sine values are effectively random bits, but anyone can recompute them. Your implementation can generate the table or hardcode it — generating it is the better lesson.
Each 512-bit block is split into sixteen 32-bit little-endian words M[0..15]. Then 64 steps run, in four rounds of sixteen. Each round uses a different mixing function — this is the heart of MD5:
F = lambda b, c, d: (b & c) | (~b & d) # round 1: "if b then c else d" — a bitwise multiplexer
G = lambda b, c, d: (b & d) | (c & ~d) # round 2: same idea, keyed on d
H = lambda b, c, d: b ^ c ^ d # round 3: parity
I = lambda b, c, d: c ^ (b | ~d) # round 4: designed to be non-symmetricEvery step performs the same operation, with per-round message-word ordering and shift amounts:
# one MD5 step
a = add(b, rotl(add(a, MIX(b, c, d), M[k], T[i]), s))
a, b, c, d = d, a, b, c # rotate the roles: each register gets updated every 4th stepThe schedule that varies per round:
| Round | Steps | Message index k |
Shifts s (cycle of 4) |
|---|---|---|---|
| 1 | 0–15 | i |
7, 12, 17, 22 |
| 2 | 16–31 | (1 + 5i) mod 16 |
5, 9, 14, 20 |
| 3 | 32–47 | (5 + 3i) mod 16 |
4, 11, 16, 23 |
| 4 | 48–63 | (7i) mod 16 |
6, 10, 15, 21 |
Each round therefore visits all sixteen message words, but in a different order — so a change in any word gets folded into the state from four different directions.
After the 64 steps, add the working registers back into the state (A = add(A, a) etc. — the feed-forward), process the next block, and at the end emit the four registers little-endian:
digest = b''.join(x.to_bytes(4, 'little') for x in (A, B, C, D)).hex()That's the whole algorithm. Sixteen lines of real logic, one afternoon to implement, one decade to break — see Part 6.
NSA design, published by NIST in 1995 (FIPS 180-1). Digest: 160 bits. State: five 32-bit registers.
SHA-1 keeps MD5's skeleton but makes two structural changes: a fifth register, and — the important one — a message schedule that expands each block before use.
h0, h1, h2, h3, h4 = 0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0The first four are MD5's counting pattern; the fifth continues it (C3 D2 E1 F0 — the same digits, interleaved).
MD5 uses each 32-bit message word directly, four times. SHA-1 instead expands the sixteen words into eighty:
W = list(struct.unpack('>16I', block)) # sixteen big-endian words
for t in range(16, 80):
W.append(rotl(W[t-3] ^ W[t-8] ^ W[t-14] ^ W[t-16], 1))Now every step consumes a word that already mixes several original words — a change anywhere in the block contaminates most of the schedule. Historical footnote worth knowing: the original 1993 standard ("SHA-0") lacked that rotl(..., 1). The NSA withdrew it and added the one-bit rotation without public explanation; cryptanalysts later confirmed SHA-0 is dramatically weaker without it. One rotation, one bit, that's the difference.
a, b, c, d, e = h0, h1, h2, h3, h4
for t in range(80):
if t < 20: f, K = (b & c) | (~b & d), 0x5A827999 # choose
elif t < 40: f, K = b ^ c ^ d, 0x6ED9EBA1 # parity
elif t < 60: f, K = (b & c) | (b & d) | (c & d), 0x8F1BBCDC # majority
else: f, K = b ^ c ^ d, 0xCA62C1D6 # parity again
a, b, c, d, e = add(rotl(a, 5), f, e, K, W[t]), a, rotl(b, 30), c, dNote the register shuffle at the end of each step: e inherits d, d inherits c, c gets a rotated copy of b, and the freshly computed value lands in a. The four round constants are nothing-up-my-sleeve too: 0x5A827999 = ⌊2^30·√2⌋, and the others are the same construction over √3, √5, √10.
Finish exactly like MD5 — feed-forward into h0..h4, next block, then emit the five registers big-endian.
NSA design, published by NIST in 2001 (FIPS 180-2), part of the SHA-2 family. Digest: 256 bits. State: eight 32-bit registers.
SHA-256 abandons the "four rounds with different functions" pattern. Instead: 64 uniform steps, each with its own additive constant, a much more aggressive message schedule, and two independent mixing paths per step. This uniformity-with-unique-constants design is a direct response to the attacks that broke its ancestors — differential attacks exploit regularity, so SHA-2 removed as much of it as possible.
# fractional parts of the square roots of the first 8 primes (2, 3, 5, 7, 11, 13, 17, 19)
H = [0x6A09E667, 0xBB67AE85, 0x3C6EF372, 0xA54FF53A,
0x510E527F, 0x9B05688C, 0x1F83D9AB, 0x5BE0CD19]
# round constants: fractional parts of the cube roots of the first 64 primes
K = [0x428A2F98, 0x71374491, 0xB5C0FBCF, 0xE9B5DBA5, ...] # 64 valuesVerify one yourself: math.sqrt(2) is 1.41421356...; the fractional part times 2^32 is 0x6A09E667.... Irrational roots make ideal nothing-up-my-sleeve numbers — patternless bits that anyone on Earth can independently recompute.
def s0(x): return rotr(x, 7) ^ rotr(x, 18) ^ (x >> 3) # σ0
def s1(x): return rotr(x, 17) ^ rotr(x, 19) ^ (x >> 10) # σ1
W = list(struct.unpack('>16I', block))
for t in range(16, 64):
W.append(add(s1(W[t-2]), W[t-7], s0(W[t-15]), W[t-16]))Compare with SHA-1's schedule: XOR has been upgraded to modular addition (carries propagate information between bit positions), and each recycled word is first shredded by a three-way rotate-and-shift mix. Note the deliberate asymmetry: two of the three terms are rotations (lossless) and one is a plain shift (lossy) — the combination is much harder to steer than SHA-1's single-bit rotation.
Each step runs two independent computations and merges them:
def S0(x): return rotr(x, 2) ^ rotr(x, 13) ^ rotr(x, 22) # Σ0
def S1(x): return rotr(x, 6) ^ rotr(x, 11) ^ rotr(x, 25) # Σ1
def ch(e, f, g): return (e & f) ^ (~e & g) # choose: e selects between f and g
def maj(a, b, c): return (a & b) ^ (a & c) ^ (b & c) # majority vote per bit
a, b, c, d, e, f, g, h = H
for t in range(64):
t1 = add(h, S1(e), ch(e, f, g), K[t], W[t]) # path 1: absorbs the message word
t2 = add(S0(a), maj(a, b, c)) # path 2: pure state mixing
h, g, f, e, d, c, b, a = g, f, e, add(d, t1), c, b, a, add(t1, t2)Read the register shuffle carefully — it's the elegant part. The eight registers behave as two chains of four: t1 is injected into the e-chain (via e = d + t1) and into the a-chain (via a = t1 + t2), so every message word immediately influences two separate diffusion paths that only fully remix over subsequent steps. ch and maj should look familiar — they're the same choose/majority functions MD5 and SHA-1 used, but here they run in every step, on different register groups, simultaneously.
Finish as always: feed-forward into H, next block, emit all eight registers big-endian — 64 hex characters.
| Feature | MD5 | SHA-1 | SHA-256 |
|---|---|---|---|
| Published | 1992 | 1995 | 2001 |
| Digest size | 128 bits | 160 bits | 256 bits |
| State registers | 4 × 32-bit | 5 × 32-bit | 8 × 32-bit |
| Compression steps | 64 (4 rounds × 16) | 80 (4 rounds × 20) | 64 (uniform) |
| Message schedule | none (words used directly) | 80 words, XOR + rot1 | 64 words, σ-mixed additions |
| Round constants | 64, from sin(i) | 4, from √2 √3 √5 √10 | 64, from prime cube roots |
| Endianness | little | big | big |
| Collision resistance | Broken (2004) | Broken (2017) | Holding |
| Legitimate use today | non-security checksums | legacy compatibility only | everywhere |
Hash functions don't fail like a dam breaking; they fail like a glacier calving — slowly, visibly, with years of warnings.
MD5. Weaknesses in the compression function were published as early as 1996 (Dobbertin). In 2004, Wang, Feng, Lai and Yu announced practical collisions — computable in hours on commodity hardware, an attack that stunned the field. It got worse: chosen-prefix collisions allow two meaningfully different documents to collide, and in 2012 the Flame malware used exactly that to forge a code-signing certificate that chained up to Microsoft's root — malware that Windows Update accepted as genuine. Today an MD5 collision costs seconds on a laptop. The birthday bound promised 2^64; reality delivered ~2^24.
SHA-1. Theoretical collision attacks below the 2^80 birthday bound appeared in 2005 (Wang again, ~2^69). The industry got a decade of warning. In 2017, Google and CWI Amsterdam published SHAttered: two real PDF files, different contents, identical SHA-1 digest — ~2^63 computations, about 110 GPU-years, entirely feasible for a well-funded attacker. In 2020, "SHA-1 is a Shambles" brought chosen-prefix collisions to practical cost and demonstrated impersonation in PGP's web of trust. Browsers had already dropped SHA-1 certificates; git has been migrating away from it.
Why did SHA-256 survive? Partly size — 256 bits puts the birthday bound at 2^128 — but mostly structure. Everything you saw in Part 4 (uniform steps with unique constants, the σ-mixed additive schedule, dual diffusion paths) removes the regularities that differential cryptanalysis needs to steer a collision through the rounds. After two decades of the best public cryptanalysis, full SHA-256 has no known attack meaningfully better than brute force.
One family-wide caveat worth knowing: every plain Merkle–Damgård hash — SHA-256 included — has the length-extension property: given hash(m) and len(m), anyone can compute hash(m ‖ padding ‖ suffix) without knowing m, because the digest is the internal state. This is why naive hash(secret + message) authentication is broken, and why HMAC exists. Understanding the construction is understanding its weakness — you now know both.
Don't trust this README — run the vectors. These are the canonical RFC 1321 / FIPS 180 test values, and python vhash.py --test checks all of them plus a cross-check against hashlib:
| Input | MD5 | SHA-1 | SHA-256 |
|---|---|---|---|
"" |
d41d8cd98f00b204e9800998ecf8427e |
da39a3ee5e6b4b0d3255bfef95601890afd80709 |
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 |
"abc" |
900150983cd24fb0d6963f7d28e17f72 |
a9993e364706816aba3e25717850c26c9cd0d89d |
ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad |
And see the avalanche effect on your own machine:
MD5:
"Hello World" -> b10a8db164e0754105b7a99be72e3fe5
"Hello Worle" -> 80773decab876436ac2b049333bcbc30 (65 of 128 bits differ)
SHA-256:
"Hello World" -> a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e
"Hello Worle" -> 03eea90a6021267e951388b7c5bf2873a6c12e38a4c9671fa3c4a52db6c767b8 (133 of 256 bits differ)
One character. Half the bits. That's the whole point of everything above.
This implementation is purely educational: it prioritizes readability over speed and has no hardware acceleration or side-channel defenses — by design. For real code:
import hashlib
digest = hashlib.sha256(b"Hello").hexdigest()Contributions, bug fixes, and clarity improvements are welcome — clarity improvements most of all, since readability is the entire point of this project. Feel free to open an Issue or submit a Pull Request.
MIT © 2026 Gabriele Vierti