SHA-256, HMAC and PBKDF2 implemented from their specifications, verified against official test vectors, and then used to forge a MAC without knowing the key.
Days 2–4 of a 30-day challenge, and all three are done: primitives verified against official vectors, an envelope-encrypted secrets vault, and three working attacks — a MAC forgery, a timing side channel, and nonce reuse.
pip install -r requirements.txt
pytest # 493 tests
python -m attacks.length_extension # forge a MAC without the key
python bench.py # how slow, and why it matters
python bench_vault.py # what envelope encryption actually buys
python -m vault.cli init # the vault
python -m attacks.timing # recover a secret from comparison timing
python -m attacks.nonce_reuse # one repeated nonce breaks two messagesThis is educational code. It is not constant-time where it matters, not side-channel hardened, and up to 4,000x slower than
hashlib. Usehashlib,hmac, andcryptography. The point of writing it was to be able to explain what those libraries are doing and why they refuse to let you do the obvious thing.
| Module | |
|---|---|
cryptolab/sha256.py |
FIPS 180-4, streaming, with internal state exposed |
cryptolab/hmac.py |
RFC 2104, built on the above |
cryptolab/pbkdf2.py |
RFC 8018 |
cryptolab/constant_time.py |
A vulnerable comparison and a correct one |
attacks/length_extension.py |
Forges a tag for SHA256(secret‖msg) |
attacks/timing.py |
Recovers a tag from comparison timing |
attacks/nonce_reuse.py |
Recovers plaintext from a reused nonce |
vault/ |
Envelope-encrypted secrets store + CLI |
Correct-looking crypto that is subtly wrong is the default outcome, so nothing here is trusted without vectors:
- SHA-256 — all four FIPS 180-4 appendix vectors, including the
one-million-
acase that catches length handling above 2^19 bits. - HMAC — all seven RFC 4231 vectors.
- PBKDF2 — RFC 6070's inputs, cross-checked against
hashlib.pbkdf2_hmac. - Differential testing — 800+ random inputs against the standard library. Official vectors prove you match the spec on the cases the spec chose. Differential testing covers the cases nobody chose, which is where padding bugs live. Every length from 0 to 129 bytes is tested explicitly, because 55/56 and 63/64 are where the padding logic changes behaviour.
493 tests: primitives, vault, and both attacks.
The obvious way to authenticate a message with a shared secret:
tag = sha256(secret + message) # brokenA SHA-256 digest is the algorithm's internal state after consuming the input. Merkle–Damgård hashes have no separate finalisation secret, so anyone holding a tag holds the state, and can resume hashing from it:
message : user=guest&role=viewer
tag : 8ffa26d8b370361f649c6815612c5d53fb1559ca5d2f71fdf445622a881584e4
(the attacker knows those two lines, and nothing else)
✓ forged with secret length 34
b'user=guest&role=viewer\x80\x00...\x01\xc0&role=admin'
server accepts: True
The attacker never learns the secret. They don't need to. The only unknown is the secret's length, because the padding they forge depends on it — and that's a search space of about a hundred, brute-forced in milliseconds.
Note the 72 bytes of glue padding sitting in the middle of the forged message.
Any parser taking the last value of a repeated key now reads role=admin.
Against HMAC the same attack fails at every length guess. The tag is the
outer hash's output, and forging one requires the outer hash to have consumed
H(inner_key ‖ message) — which needs the key. There's nothing to extend.
That is the entire reason HMAC's nested construction exists, and it's a much better answer to "why not just hash the key with the message" than "because you shouldn't".
python bench.py:
| input | mine | hashlib | ratio |
|---|---|---|---|
| 64 B | 212 µs | 0.41 µs | 516x |
| 1 KB | 1,789 µs | 0.79 µs | 2,273x |
| 64 KB | 109 ms | 27 µs | 4,003x |
HMAC-SHA256 on 1 KB: 1,102x slower than hmac.
The gap widens with input size, which is the interesting part. At 64 bytes most
of hashlib's time is call overhead; at 64 KB it's doing real work at 2.4 GB/s
against my 0.6 MB/s. That 4,000x is Python's arbitrary-precision integers —
every operation masks back to 32 bits, where C gets the register width for free
— plus OpenSSL's hand-written SHA-NI instructions.
| iterations | time | guesses/sec, one core |
|---|---|---|
| 1,000 | 0.2 ms | 5,353 |
| 10,000 | 1.9 ms | 536 |
| 100,000 | 18.7 ms | 53 |
| 600,000 | 112.6 ms | 9 |
600,000 is OWASP's current recommendation for PBKDF2-HMAC-SHA256. One core manages nine guesses a second — but a GPU runs thousands in parallel, because each PBKDF2 guess needs a few registers and essentially no memory.
That asymmetry is the whole argument for Argon2id. PBKDF2 is compute-hard; GPUs have thousands of compute units. Argon2id is deliberately memory-hard — make each guess require megabytes and a GPU's core count stops being an advantage, because memory bandwidth becomes the bottleneck.
python -m vault.cli init
python -m vault.cli add prod-db --generate
python -m vault.cli list
export TOKEN=$(python -m vault.cli get prod-db)
python -m vault.cli rotate-passwordmaster password --Argon2id--> KEK
├── wraps ──> data key 1 ──encrypts──> secret 1
└── wraps ──> data key 2 ──encrypts──> secret 2
Every secret gets its own random data key. The key-encryption key never touches a secret directly — it only encrypts data keys.
The reason is rotation. Changing the master password re-derives the KEK and re-wraps N 32-byte data keys, without ever decrypting a secret. Without the envelope, changing your password means decrypting and re-encrypting everything you own.
Argon2id rather than PBKDF2, for the reason Day 2's benchmark makes concrete: PBKDF2 is compute-hard and GPUs are compute. Defaults are 64 MB memory cost, 3 passes.
Every ciphertext is bound to its record:
aead.encrypt(data_key, secret, associated_data=name.encode())Without that, an attacker with write access to the vault file could move your
staging-db ciphertext — plus its wrapped key, so it decrypts perfectly — into
the prod-db record. Nothing about the crypto would object. You'd connect to
production with staging credentials, or worse, the reverse.
test_ciphertexts_cannot_be_swapped_between_records performs exactly that
attack and asserts it fails.
The interesting ones don't check that a secret round-trips. They take the role of someone with write access to the file:
- flip one bit of a ciphertext → detected
- flip one bit of a wrapped key → detected
- truncate a ciphertext → detected
- swap two complete records → detected (this is the AAD test)
- rename a record → detected
- use one secret's data key on another's ciphertext → fails
- tamper with the KDF parameters in the header → wrong password error
Plus the properties that are easy to get wrong and impossible to notice: two identical secrets must produce different ciphertexts, rewriting the same value must change the ciphertext, and rotation must use a fresh salt.
python bench_vault.py, varying secret size across 100 secrets:
| size | envelope (crypto) | envelope (total) | naive |
|---|---|---|---|
| 64 B | 4.1 ms | 7.3 ms | 7.1 ms |
| 4 KB | 4.2 ms | 7.8 ms | 10.3 ms |
| 64 KB | 5.4 ms | 49.8 ms | 68.3 ms |
| 1 MB | 8.9 ms | 1,497 ms | 1,061 ms |
The key work is flat, exactly as designed — 4.1 ms to 8.9 ms while the data grows 16,000x, because rotation only ever moves 32-byte data keys.
And the total time is worse than doing it the naive way.
The first version of this benchmark reported only the total, showed envelope
rotation growing with secret size, and appeared to disprove the entire design.
Splitting crypto from I/O found the real cause: _save() rewrites the whole
JSON file, every ciphertext included, so rotation pays for every byte stored no
matter how good the key hierarchy is.
The storage format eats the architectural win. That's not a bug in envelope encryption — it's the reason real KMS-backed systems keep ciphertexts in S3 objects or database rows, so rotating the master key touches only key metadata. A single-file vault can never get that benefit.
I'd rather ship the honest table than quietly drop the benchmark that didn't say what I wanted.
Two attacks against the vault's own primitives. Both are defensive: they run against code in this repo, to show what the defended version prevents.
python -m attacks.timingcryptolab.constant_time.naive_equals returns the instant it finds a mismatched
byte. So a guess sharing more leading bytes with the real tag takes fractionally
longer to reject, and that fraction leaks the secret one byte at a time — turning
a 2^64 brute force into about 256 × 8 timed guesses:
secret tag : 9a17c300ff428e6d
Against naive_equals (returns early):
recovered : 9a17c300ff428e6d
correct : True
method : 7 bytes by timing, last byte by 256-guess brute force
mean per-byte confidence: 8.5σ above the field
Against constant_time_equals (examines every byte):
timing-recovered 0/7 bytes — around what blind guessing gives
mean per-byte confidence: 0.2σ
Three details that make it actually work, none of which are obvious:
- The signal is one Python loop iteration — tens of nanoseconds under microseconds of noise. Each measurement batches 200 comparisons so the difference accumulates into something measurable. That models a real attacker averaging repeated requests; it doesn't manufacture signal that isn't there.
- Measurements are interleaved. Every round times all 256 candidates once, and the per-candidate minimum is kept across rounds. Round-robin spreads slow drift (the CPU warming, a background task waking) evenly instead of dumping it on whichever candidates were measured first — this is what makes it reliable at 25 rounds instead of thousands. The minimum works because timing noise is strictly one-sided: interrupts only ever slow a sample down.
- The last byte carries no timing signal at all. Matching it doesn't make
the loop run longer — it returns
Trueafter the same number of comparisons a wrong guess returnsFalse. So the attack recovers 7 bytes by timing and finds the 8th with a 256-guess brute force against the oracle's accept.
The lesson isn't "the comparison is slow". It's that its duration depends on
the secret. constant_time_equals examines every byte regardless, leaks 0.2σ,
and the attack collapses to blind guessing.
python -m attacks.nonce_reuseEvery stream cipher — ChaCha20, AES-CTR, AES-GCM — is ciphertext = plaintext XOR keystream(key, nonce). The keystream depends only on the key and nonce.
Encrypt two messages under the same pair and:
c1 XOR c2 == p1 XOR p2 (the key cancels out completely)
The attacker now has the XOR of two plaintexts with the key gone. Crib-dragging a guessed phrase recovers both:
assumed p1 : b'Transfer $100 to Alice, authorized by '
yields p2 : b'Transfer $999 to Eve!! authorized by n'
No key strength helps — the key was eliminated by the XOR. This is why
vault/aead.py generates a fresh random nonce on every encryption, and it's
what makes AES-GCM so dangerous with any counter you might reset: a rolled-back
VM snapshot, a config copied to a second host. One repeated (key, nonce) and
confidentiality is gone.
Both attacks are backed by tests (test_timing.py, test_nonce_reuse.py) that
assert the structural facts — the correct byte is the slowest candidate, the
key cancels in the XOR — rather than flaky wall-clock thresholds.
Why is Argon2id preferred over PBKDF2, and what specifically does it defend
against?
Parallel hardware. PBKDF2's cost is sequential compute, which a GPU or ASIC
parallelises across thousands of candidate passwords — each guess needs almost
no memory, so an attacker fits thousands of them on one card. Argon2id requires
a configurable amount of memory per guess (typically 64 MB+). A GPU with 24 GB
can then run a few hundred guesses at once instead of tens of thousands. The
id variant combines data-independent addressing in the first pass (resisting
side-channel attacks that recover the password from memory access patterns) with
data-dependent addressing afterwards (resisting time-memory tradeoffs).
What does the length-extension attack need that isn't public? Only the secret's length, and only because the forged glue padding depends on it. Everything else — message, tag, algorithm — is already known to the attacker. That's why the fix isn't "keep the length secret"; a hundred guesses is not a defence.
Why does hashing a long HMAC key produce a surprising equality?
Keys longer than 64 bytes are replaced by their digest, so a 200-byte key and
the 32-byte SHA-256 hash of that key are literally the same key to HMAC.
test_long_key_is_hashed_first pins it. It matters if you ever assume distinct
key material produces distinct MACs.
Why does envelope encryption make key rotation cheap, and what did the benchmark reveal about that claim? The KEK only ever encrypts 32-byte data keys, so re-keying is flat in secret size — measured at 4.1 ms to 8.9 ms while the data grew 16,000x. But the claim only holds for the crypto. Total rotation time still grew, because this vault rewrites one JSON file containing every ciphertext. The design is right and the storage format defeats it, which is precisely why KMS-backed systems store ciphertexts as separate objects.
What does AEAD's associated data protect, and what attack does it stop?
It authenticates context that isn't encrypted. Here the secret's name is the
AAD, which binds each ciphertext to its record. The attack it stops is record
swapping: without it, someone with write access moves the staging-db
ciphertext and its wrapped key into the prod-db entry, everything decrypts
cleanly, and you connect to the wrong database with no error anywhere.
Why does a byte-by-byte comparison leak a secret, and why does the last byte
resist?
Early return. The comparison stops at the first mismatch, so its duration
encodes how many leading bytes were correct — measure that and recover the tag
one byte at a time. The final byte is special: matching it doesn't extend the
loop, it only flips the return value after the same number of comparisons, so it
carries no timing signal and falls to a 256-guess brute force instead. The fix
is constant_time_equals, which examines every byte regardless and leaked 0.2σ
against the attack's 8.5σ.
Why is reusing a nonce catastrophic rather than merely bad?
A stream cipher's keystream is a function of (key, nonce) only. Two messages
under the same pair give c1 XOR c2 = p1 XOR p2 — the key cancels entirely, so
key strength is irrelevant, and crib-dragging peels both plaintexts apart. It's
not a downgrade in security, it's a total loss of confidentiality for both
messages, caused by one repeated value. This is why nonces must be random or a
never-reset counter, and why a rolled-back VM snapshot is a real AES-GCM risk.
Why encode the message length in the padding at all?
Without it, "abc" and "abc\x80\x00…" could pad to identical blocks and
collide trivially. Encoding the original bit length makes every distinct message
produce a distinct padded input — Merkle–Damgård strengthening.
Why add the working variables back into the state at the end of each block? Without that feed-forward the compression function would be invertible, and a hash you can run backwards is not a hash. It's the Davies–Meyer construction.
I'd write the length-extension attack before the SHA-256 implementation next
time. Building the attack forced me to actually understand Merkle–Damgård — that
a digest is the internal state — and I only reached that understanding after the
primitive was done, which is backwards. I'd also keep the "educational from
scratch" and "use the real library" boundary sharper from the start; exposing
the hash's internal state for the attack demo blurred a line that in production
must never blur. And I'd add a property-based fuzzer against hashlib from day
one rather than adding differential tests after the fact.
- Not constant-time. Python can't guarantee it — the interpreter, GC and small
integer caching all leak.
constant_time_equalsis the right algorithm, but the guarantee only holds in C. - No SHA-224/384/512, no HMAC over other hashes.
Sha256exposes its internal state, which a real library would never do. It's public here because the attack demo needs it.- Argon2id comes from
argon2-cffi, not from scratch. Implementing it correctly is a project of its own. - The vault leaks what you store, just not what it is. Secret names, the KDF parameters and timestamps are all plaintext in the file. Hiding the index needs a different design — fixed-size padded records and an encrypted index.
- Rotation rewrites the entire file, so it's linear in total bytes stored. See the benchmark.
- Secrets sit in Python
bytes, which cannot be reliably zeroed — the GC may have copied them anywhere. Real vaults usemlocked buffers outside the managed heap. - No concurrent access control. Two processes writing at once will lose data, atomic rename or not.