Skip to content
wannacode

Cryptography you don't write yourself

In short: You do not write cryptography yourself: in Python the secrets module gives random numbers, hmac.compare_digest compares in constant time, and AES comes from the cryptography package.

Problem

The code somebody already knows

The shop sends a 6-digit code for "forgot password". The same generator also makes the discount codes in the newsletter. The code looks random: 807592, 213904, 566120.

One night somebody changes a buyer’s password and shops with their card. He did not guess a million codes: he tried one. He knew the code before the buyer opened the email.

The generator is random.Random(started), and started is the second the shop started.

The idea

Random is a formula, not randomness

Random is a pseudorandom number generator: a formula that, from one number (the seed), computes a sequence that looks random. The same seed gives the same sequence. For games, simulations and tests that is useful. For secrets it is a disaster: whoever knows the seed, or can guess it, knows every next number.

The shop’s seed is the second it started. The attacker knows the shop was restarted in the night: 3600 possibilities. He has his own discount code from the newsletter. He tries every seed until the first code matches his. Then the next code of his copy is the buyer’s code.

For everything that has to be secret and unpredictable (tokens, codes, keys, salts, nonces) you use a cryptographic generator. It takes randomness from the operating system (hardware events, noise) and is built so that its next output cannot be computed from what came before.

See it

The attacker tries every second

def reset_token():
return secrets.token_urlsafe(32)
def code():
return f"{secrets.randbelow(1_000_000):06d}"
Step by step, in words
  1. The shop makes its codes with Random, and the seed is the second it started: 03:40:17. The first code goes into the newsletter, to the attacker as well.
  2. The next number from the same generator is the password reset code for a buyer. It goes by email.
  3. The attacker knows the shop restarted in the night. He tries every second from 03:00: one generator per second.
  4. At try 2418 the first code is the same as his discount code. That is the seed: 03:40:17.
  5. The next number of his copy is the buyer’s reset code. He changes the password and takes the account.
  6. The fix: the bytes come from the operating system’s cryptographic generator. There is no seed to guess.
  7. 3600 tries, or 3 million: no match. The token is 32 random bytes, and even a 6-digit code is no longer predictable.
def code(rng):
return f"{rng.randrange(1_000_000):06d}"
def predict_next(seen, start, window):
for i in range(window):
guess = random.Random(start + i)
if code(guess) == seen:
print(f"Seed: try {i + 1}")
return code(guess)
return None
night = 1_793_070_000 # 27.10.2026, 03:00 UTC
started = night + 2_417 # the shop restarted at 03:40:17
rng = random.Random(started)
discount = code(rng)
reset = code(rng)
print(f"Discount code from the newsletter (the attacker has it): {discount}")
predicted = predict_next(discount, night, 3_600)
print(f"Predicted password reset code: {predicted}")
print(f"The real code sent to the buyer: {reset}")
Output
Discount code from the newsletter (the attacker has it): 573765
Seed: try 2418
Predicted password reset code: 271558
The real code sent to the buyer: 271558

The codes are different in the four languages, because the generators are different. The try is the same: 2418, because the seed is 03:40:17.

The idea

The cryptographic generator in every language

def reset_token():
return secrets.token_urlsafe(32)
def code():
return f"{secrets.randbelow(1_000_000):06d}"
first = reset_token()
second = reset_token()
print(f"Token length: {len(first)}")
print(f"Only A–Z, a–z, 0–9, - and _: {re.fullmatch(r'[A-Za-z0-9_-]+', first) is not None}")
print(f"Two tokens in a row differ: {first != second}")
print(f"The code is 6 digits: {re.fullmatch(r'[0-9]{6}', code()) is not None}")
Output
Token length: 43
Only A–Z, a–z, 0–9, - and _: True
Two tokens in a row differ: True
The code is 6 digits: True

The token is 32 bytes (256 bits), written in URL-safe Base64: 43 characters that can go into a link. Guessing it is as hard as guessing an AES-256 key.

The secrets module is made exactly for this: token_urlsafe, token_hex, token_bytes, randbelow, choice. The random module is for simulations; its documentation says so in a red box.

Try it

Predict the output

Two generators with the same seed. What will it print?

a = random.Random(42)
b = random.Random(42)
print(a.randrange(100) == b.randrange(100))
print(a.randrange(100) == b.randrange(100))
Answer

Try it

Fill in

A password reset token from the cryptographic generator:

token = secrets.(32)

Try it

Find the bug

The token is long and looks safe. Which line makes it predictable?

Pick the line with the bug.

The idea

A comparison that gives nothing away

When the shop checks the code from the email, it compares it with the stored one. An ordinary string comparison stops at the first difference. The more leading characters are right, the longer it takes. Over the network the difference is nanoseconds, but with many tries and statistics it can be measured. That way the attacker guesses character by character: 10 tries for the first, 10 for the second…

The program below does not measure time: it counts how many characters the comparison looks at, which is what makes the time differ.

# What == does inside: it stops at the first difference
def compared_chars(secret, guess):
i = 0
while i < len(secret) and secret[i] == guess[i]:
i += 1
return min(i + 1, len(secret))
secret = "481937"
for guess in ["000000", "400000", "480000", "481000"]:
print(f"{guess}: characters compared {compared_chars(secret, guess)}")
for guess in ["481000", "481937"]:
print(f"{guess}: compare_digest = {hmac.compare_digest(secret, guess)}")
Output
000000: characters compared 1
400000: characters compared 2
480000: characters compared 3
481000: characters compared 4
481000: compare_digest = False
481937: compare_digest = True

A constant-time comparison looks at every byte, whatever it finds, and the time does not depend on how much was guessed. Every language has it ready: hmac.compare_digest. Use it for everything secret that comes from outside: codes, tokens, JWT signatures, HMACs.

The idea

Encryption: a ready-made algorithm from a ready-made library

The shop keeps the IBAN of buyers who return goods. In the database it must be encrypted, and on reading it must be certain that nobody changed it. Both are done by authenticated encryption. The standard is AES-GCM: it encrypts and adds a 16-byte tag. If a single bit changes, the tag does not match and decryption stops with an error.

The Python standard library has no AES. It has hashlib, hmac and secrets, but no encryption. People use the cryptography package. Here is how it looks with it; it is not run here, because the site’s code check uses only the standard library:
# pip install cryptography: it is not in the standard library
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
def encrypt(key, nonce, text):
return AESGCM(key).encrypt(nonce, text.encode(), None)
def decrypt(key, nonce, box):
return AESGCM(key).decrypt(nonce, box, None).decode() # InvalidTag if anything was changed

With the standard library alone you can guard against tampering with HMAC, but you cannot hide the text:

def seal(key, text):
data = text.encode()
tag = hmac.new(key, data, hashlib.sha256).digest()
return data + tag
def unseal(key, box):
data, tag = box[:-32], box[-32:]
expected = hmac.new(key, data, hashlib.sha256).digest()
if not hmac.compare_digest(tag, expected):
raise ValueError("tag")
return data.decode()
key = secrets.token_bytes(32) # in the real shop: from the secret store
box = seal(key, "BG80 BNBG 9661 1020 3456 78")
print(f"Sealed: {len(box)} bytes")
print(f"Opened: {unseal(key, box)}")
tampered = bytearray(box)
tampered[5] ^= 1
try:
unseal(key, bytes(tampered))
except ValueError:
print("One byte changed: the tag check failed, refusing")
Output
Sealed: 59 bytes
Opened: BG80 BNBG 9661 1020 3456 78
One byte changed: the tag check failed, refusing

The IBAN here is in plain view: 27 bytes of text and a 32-byte tag. This is integrity without secrecy. For secrecy you need AES-GCM from cryptography. In the real shop the key comes from the secret store, as in the lesson on secrets.

See it

A scheme of your own, broken in a few lines

A colleague decides AES is "too complicated" and writes his own encryption: every byte XOR a byte of a secret word. It looks like noise. But XOR undoes itself, and every invoice starts with the same words:

def xor(data, key):
return bytes(b ^ key[i % len(key)] for i, b in enumerate(data))
key = "SHOP2026".encode() # the shop’s "secret" key
invoice = xor("INVOICE 2026-0142, total 249.90 €".encode(), key)
iban = xor("BG80 BNBG 9661 1020 3456 78".encode(), key)
# the attacker: every invoice starts like this
known = "INVOICE ".encode()
found = xor(invoice[:8], known[:8])
print(f"The key is: {found.decode()}")
print(f"Another message: {xor(iban, found).decode()}")
Output
The key is: SHOP2026
Another message: BG80 BNBG 9661 1020 3456 78

The secret word comes out of the first 8 bytes of one invoice, and with it everything ever encrypted can be read. No maths and no billions of tries are needed, only a known start of the text.

That is the rule of this lesson: do not invent your own cryptography. Not your own algorithm, not your own scheme around a ready-made algorithm, not your own generator. The people who make cryptography publish their schemes and wait years for others to break them. You use their work through a library.

More precisely, if you are curious

Which for what.

  • Passwords: a slow hash with a salt (bcrypt, argon2id), the lesson on passwords. Never encryption: a password must not be readable back.
  • Signing a message with a shared key: HMAC-SHA256, as in JWT.
  • Encrypting data: AES-GCM (or ChaCha20-Poly1305) with a key from a secret store.
  • Tokens and codes: the cryptographic generator.
  • A hash for the integrity of a file: SHA-256 or SHA-512, as in the lesson on dependencies. MD5 and SHA-1 are broken for this.

Higher-level libraries. AES-GCM still requires you never to repeat a nonce and to guard the key. Libraries like libsodium (NaCl), Google Tink and Fernet in Python’s cryptography hide these decisions too: you get encrypt(key, text) and decrypt(key, box), with no modes and no nonce.

Constant-time comparison inside. It compares every byte and collects the differences with XOR and OR: diff |= a[i] ^ b[i], and at the end checks diff == 0. There is no early exit. You do not write it yourself: the compiler may "optimise" it; the ready-made functions are built so that this cannot happen.

The 6-digit code. Even from a cryptographic generator, 6 digits are a million possibilities. So the code expires after minutes and locks after a few wrong tries. A token in a link is 32 bytes, and there these limits are not needed against guessing, only against leaks (in a log, for example).

UUID. UUID.randomUUID() in Java, uuid.uuid4() in Python and crypto.randomUUID() in Node come from a cryptographic generator. Guid.NewGuid() in .NET does too in practice, but the documentation does not promise it, so for secrets use RandomNumberGenerator. A UUID itself is 122 random bits: fine for an id, but a 32-byte token is clearer.

Exercise

Discount codes

Solve it in your own editor.

The shop wants 10-character discount codes that are easy to read: only capital Latin letters and digits, without 0, O, 1 and I, which get confused. The alphabet is ABCDEFGHJKLMNPQRSTUVWXYZ23456789 (32 characters, 50 bits per code). Write discount_code with the cryptographic generator, make 1000 codes and check that all are from the alphabet and all are distinct:

Output
Codes: 1000
Each is 10 characters from the alphabet: True
Distinct: 1000
Hint: secrets.choice(alphabet) picks one character.

More tasks

  • Expiry. Store when each code was made and refuse codes older than 15 minutes.
  • Tries. Lock the code after 5 wrong tries and count the tries.
  • No modulo. Why is ALPHABET[randomByte % 32] fine, but ALPHABET[randomByte % 30] is not? (Hint: 256 divides by 32, but not by 30.)

Test

Check yourself

1. Why is a code from a seeded Random dangerous for a password reset, even if it is 32 characters long?
2. Which one is a cryptographic generator?
3. Why is the token compared with MessageDigest.isEqual / FixedTimeEquals / compare_digest / timingSafeEqual and not with ==?
4. The shop "encrypts" invoices with XOR and a secret 8-letter word. Every invoice starts with "INVOICE ". How hard is it to find the word?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·