Skip to content

Latest commit

 

History

6 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 

Repository files navigation

VHash — Cryptographic Hash Functions, Implemented From Scratch

Python Version License Dependencies

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 hashlib module.

Contents

Quick start

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 walkthrough

Exit codes: 0 on success, 1 on file/IO errors, 2 on an unknown algorithm. Errors go to stderr. The API mirrors hashlibupdate() / hexdigest() — and streams files in fixed-size chunks, so nothing is ever loaded fully into memory.


Part 0 — What a hash function actually promises

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:

  1. Preimage resistance — given a digest h, it must be infeasible to find any message m with hash(m) = h. (You can't run it backwards.)
  2. Second-preimage resistance — given a message m1, it must be infeasible to find a different m2 with the same digest. (You can't forge a substitute for a specific document.)
  3. 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.


Part 1 — The shared machinery

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.

The Merkle–Damgård construction

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.

Padding, with a worked example

All three algorithms pad identically in structure:

  1. Append a single 1 bit — in byte terms, 0x80.
  2. Append 0x00 bytes until the message length is 56 mod 64 bytes (i.e. 8 bytes short of a full block).
  3. 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 ]

abc0x80 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 message

Endianness: the detail that will bite you

That 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.)

Faking 32-bit hardware in Python

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))) & MASK

Why 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.


Part 2 — MD5, step by step

Ronald Rivest, 1992 (RFC 1321). Digest: 128 bits. State: four 32-bit registers.

Initial state

A, B, C, D = 0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476

Look 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.

The constant table

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.

The compression function: 4 rounds × 16 steps

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-symmetric

Every 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 step

The 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.

Finishing

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.


Part 3 — SHA-1, step by step

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.

Initial state

h0, h1, h2, h3, h4 = 0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0

The first four are MD5's counting pattern; the fifth continues it (C3 D2 E1 F0 — the same digits, interleaved).

The message schedule

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.

The compression function: 4 rounds × 20 steps

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, d

Note 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.


Part 4 — SHA-256, step by step

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.

Initial state and constants — square roots and cube roots

# 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 values

Verify 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.

The message schedule — now with sigmas

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.

The compression function: 64 uniform steps

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.


Part 5 — Side-by-side comparison

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

Part 6 — How MD5 and SHA-1 died

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.

Verify everything

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.

Production security note

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()

Contributing

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.

License

MIT © 2026 Gabriele Vierti

About

cryptographic hash functions, implemented from scratch

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Contributors

Languages