Skip to content

Repository files navigation

Applied Cryptography

tests

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 messages

This is educational code. It is not constant-time where it matters, not side-channel hardened, and up to 4,000x slower than hashlib. Use hashlib, hmac, and cryptography. 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.

What's here

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

Verification

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-a case 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 length-extension attack

The obvious way to authenticate a message with a shared secret:

tag = sha256(secret + message)     # broken

A 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".

Benchmarks

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.

What the numbers say about password hashing

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.

The vault (Day 3)

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

Envelope encryption

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

The secret's name is the associated data

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.

What the tests actually test

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.

Rotation, measured — and the result that broke the claim

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.

Breaking it (Day 4)

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.

Timing attack — recovering a secret from a comparison

python -m attacks.timing

cryptolab.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 True after the same number of comparisons a wrong guess returns False. 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.

Nonce reuse — one repeated nonce destroys two messages

python -m attacks.nonce_reuse

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

Depth questions

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.

What I'd do differently

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.

Known gaps

  • Not constant-time. Python can't guarantee it — the interpreter, GC and small integer caching all leak. constant_time_equals is the right algorithm, but the guarantee only holds in C.
  • No SHA-224/384/512, no HMAC over other hashes.
  • Sha256 exposes 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 use mlocked buffers outside the managed heap.
  • No concurrent access control. Two processes writing at once will lose data, atomic rename or not.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages