DEV Community

Cover image for The Line of Code That Makes Every Encryption Unique — Even With the Same Password
Bijan
Bijan

Posted on AI-assisted

The Line of Code That Makes Every Encryption Unique — Even With the Same Password

Same message. Same password. Same key. Two completely different ciphertexts. That's not a bug — it's a requirement, and it comes down to a single value that's easy to overlook: the nonce.

This is the final post in a series documenting what I'm learning building CryptoGraphy.

Why identical ciphertext for identical plaintext is a real problem

If the same plaintext always produced the same ciphertext under a given key, an attacker watching encrypted traffic could spot patterns without ever breaking the encryption itself. "This exact ciphertext shows up every time this user logs in." "This ciphertext matches the one sent yesterday, so the underlying message is probably identical." None of that requires cracking the key — it's leaking information through metadata and repetition, which is its own category of failure separate from confidentiality.

Where the nonce comes in

In crypto.py, a fresh nonce is generated for every single call to encrypt():

NONCE_SIZE = 12

def encrypt(plaintext: bytes, key: bytes) -> tuple[bytes, bytes]:
    nonce = os.urandom(NONCE_SIZE)
    aes = AESGCM(key)
    ciphertext = aes.encrypt(nonce, plaintext, None)
    return nonce, ciphertext
Enter fullscreen mode Exit fullscreen mode

That 12-byte nonce gets mixed into the AES-GCM encryption process, and its presence is what guarantees the output is unique every time — even with the exact same key and the exact same message. It's returned alongside the ciphertext because it needs to be available again at decryption time:

def decrypt(ciphertext: bytes, key: bytes, nonce: bytes) -> bytes:
    aes = AESGCM(key)
    return aes.decrypt(nonce, ciphertext, None)
Enter fullscreen mode Exit fullscreen mode

The nonce doesn't need to be secret — it needs to never repeat

This mirrors the salt from earlier in this series, and it trips people up the same way. The nonce isn't hidden — it's stored right alongside the ciphertext, in the clear. Its job was never secrecy. Its only job is uniqueness: it must never repeat under the same key. That's it. That's the entire requirement, and it's also the requirement that's dangerously easy to violate without realizing it.

What actually happens if you reuse one

This is the part that makes nonce handling load-bearing rather than a formality. Reuse a nonce with AES-GCM under the same key — even once — and you don't just weaken the encryption slightly. You can fully break the confidentiality of both messages encrypted with that repeated nonce, and depending on what an attacker can access, potentially recover the authentication key used for the integrity tag as well, undermining the tamper-detection property covered in the previous post too.

This isn't a theoretical edge case in an academic paper. Nonce reuse under AES-GCM is one of the most common real-world implementation bugs in systems that otherwise look like they got everything else right — it's happened in production systems, including ones built by teams who clearly understood the rest of the AEAD design. The failure mode is specifically nasty because everything looks fine until the exact moment a nonce collides, at which point the damage isn't "slightly weaker encryption" — it's a full break.

Why "generate it fresh, every call" is the whole mitigation

os.urandom(12) on every single call to encrypt() is the entire fix. A 12-byte (96-bit) random nonce, generated properly, makes accidental collision astronomically unlikely for any realistic volume of messages under a single key. The mitigation isn't complicated — it's just non-negotiable. Caching a nonce, deriving it predictably, or reusing one across calls "just this once" is exactly the kind of shortcut that looks harmless in a demo and catastrophic in production.

The takeaway across this whole series

Looking back across all five posts: a derived key instead of a raw password, a random salt, a deliberately slow and memory-hard KDF, an AEAD cipher instead of plain AES, and a fresh nonce on every call. None of these individually are exotic. Every one of them is a single function call or a small config value. And every single one of them is a place where skipping the "obvious" step quietly turns working code into broken security.

That's been the actual lesson of building this project: applied cryptography isn't hard because the concepts are exotic. It's hard because almost every step has a version that looks correct, runs without errors, and is still wrong in a way that only shows up under attack.


Full project (with all five concepts implemented) is at CryptoGraphy.

Have you seen a real nonce-reuse vulnerability in the wild? I'd genuinely like to hear the story.

Top comments (0)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.