Skip to content
wannacode

Cryptography you don't write yourself

In short: Cryptography comes from the standard library, you do not write it yourself: cryptographically secure random numbers, constant-time comparison and authenticated encryption such as AES-GCM.

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

static final SecureRandom RANDOM = new SecureRandom();
static String resetToken() {
byte[] bytes = new byte[32];
RANDOM.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding()
.encodeToString(bytes);
}
static String code() {
return String.format("%06d", RANDOM.nextInt(1_000_000));
}
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.
static String code(Random random) {
return String.format("%06d", random.nextInt(1_000_000));
}
static String predictNext(String seen, long from,
int window) {
for (int i = 0; i < window; i++) {
Random guess = new Random(from + i);
if (code(guess).equals(seen)) {
System.out.println("Seed: try " + (i + 1));
return code(guess);
}
}
return null;
}
long night = 1_793_070_000L; // 27.10.2026, 03:00 UTC
long started = night + 2_417; // the shop restarted at 03:40:17
Random random = new Random(started);
String discount = code(random);
String reset = code(random);
System.out.println("Discount code from the newsletter (the attacker has it): " + discount);
String predicted = predictNext(discount, night, 3_600);
System.out.println("Predicted password reset code: " + predicted);
System.out.println("The real code sent to the buyer: " + reset);
Output
Discount code from the newsletter (the attacker has it): 727726
Seed: try 2418
Predicted password reset code: 807592
The real code sent to the buyer: 807592

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

static final SecureRandom RANDOM = new SecureRandom();
static String resetToken() {
byte[] bytes = new byte[32];
RANDOM.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding()
.encodeToString(bytes);
}
static String code() {
return String.format("%06d", RANDOM.nextInt(1_000_000));
}
String first = resetToken();
String second = resetToken();
System.out.println("Token length: " + first.length());
System.out.println("Only A–Z, a–z, 0–9, - and _: " + first.matches("[A-Za-z0-9_-]+"));
System.out.println("Two tokens in a row differ: " + !first.equals(second));
System.out.println("The code is 6 digits: " + code().matches("\\d{6}"));
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.

SecureRandom is created once and reused: the first use gathers randomness from the system and can be slow. UUID.randomUUID() uses SecureRandom too.

Try it

Predict the output

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

Random a = new Random(42);
Random b = new Random(42);
System.out.println(a.nextInt(100) == b.nextInt(100));
System.out.println(a.nextInt(100) == b.nextInt(100));
Answer

Try it

Fill in

A password reset token from the cryptographic generator:

static final SecureRandom RANDOM = new ();

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 equals does inside: it stops at the first difference
static int comparedChars(String secret, String guess) {
int i = 0;
while (i < secret.length() && secret.charAt(i) == guess.charAt(i)) {
i++;
}
return Math.min(i + 1, secret.length());
}
String secret = "481937";
for (String guess : List.of("000000", "400000", "480000", "481000")) {
System.out.println(guess + ": characters compared " + comparedChars(secret, guess));
}
byte[] expected = secret.getBytes(StandardCharsets.UTF_8);
for (String guess : List.of("481000", "481937")) {
byte[] given = guess.getBytes(StandardCharsets.UTF_8);
System.out.println(guess + ": MessageDigest.isEqual = " + MessageDigest.isEqual(expected, given));
}
Output
000000: characters compared 1
400000: characters compared 2
480000: characters compared 3
481000: characters compared 4
481000: MessageDigest.isEqual = false
481937: MessageDigest.isEqual = 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: MessageDigest.isEqual. 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.

AES-GCM is in the standard library:
static byte[] encrypt(SecretKey key, byte[] nonce, String text) throws Exception {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(128, nonce));
return cipher.doFinal(text.getBytes(StandardCharsets.UTF_8));
}
static String decrypt(SecretKey key, byte[] nonce, byte[] box) throws Exception {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(128, nonce));
return new String(cipher.doFinal(box), StandardCharsets.UTF_8);
}
KeyGenerator generator = KeyGenerator.getInstance("AES");
generator.init(256);
SecretKey key = generator.generateKey(); // in the real shop: from the secret store
byte[] nonce = new byte[12];
new SecureRandom().nextBytes(nonce); // a new one for every message
byte[] box = encrypt(key, nonce, "BG80 BNBG 9661 1020 3456 78");
System.out.println("Encrypted: " + box.length + " bytes");
System.out.println("Decrypted: " + decrypt(key, nonce, box));
box[5] ^= 1;
try {
decrypt(key, nonce, box);
} catch (AEADBadTagException e) {
System.out.println("One byte changed: the tag check failed, refusing");
}
Output
Encrypted: 43 bytes
Decrypted: BG80 BNBG 9661 1020 3456 78
One byte changed: the tag check failed, refusing
The encrypted IBAN is 27 bytes of text and 16 bytes of tag. The key is 32 random bytes, and in the real shop it comes from the secret store, as in the lesson on secrets. The nonce is 12 random bytes, new for every message: two texts with one key and one nonce reveal the link between them. The nonce is not secret and is stored next to the encrypted text.

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:

static byte[] xor(byte[] data, byte[] key) {
byte[] out = new byte[data.length];
for (int i = 0; i < data.length; i++) {
out[i] = (byte) (data[i] ^ key[i % key.length]);
}
return out;
}
byte[] key = "SHOP2026".getBytes(StandardCharsets.UTF_8); // the shop’s "secret" key
byte[] invoice = xor("INVOICE 2026-0142, total 249.90 €".getBytes(StandardCharsets.UTF_8), key);
byte[] iban = xor("BG80 BNBG 9661 1020 3456 78".getBytes(StandardCharsets.UTF_8), key);
// the attacker: every invoice starts like this
byte[] known = "INVOICE ".getBytes(StandardCharsets.UTF_8);
byte[] found = xor(Arrays.copyOf(invoice, 8), Arrays.copyOf(known, 8));
System.out.println("The key is: " + new String(found, StandardCharsets.UTF_8));
System.out.println("Another message: " + new String(xor(iban, found), StandardCharsets.UTF_8));
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 discountCode 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

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 ·