I used to think encryption was simple: take a password, use it as the key, done. Then I built a small encryption tool myself, and realized that's not how any of this works.
This is the first post in a series where I'm documenting what I'm actually learning while building CryptoGraphy, a small Python project I'm using to study applied cryptography properly instead of just calling library functions and hoping they're right. My background is in SOC analysis and pentesting — I'm used to finding broken crypto, not building it. Writing this project is forcing me to understand the "why" behind the fixes I used to just recommend.
The naive approach
If you've never dug into how encryption actually works, this looks completely reasonable:
AES.encrypt(password, data)
Pass in a password, get encrypted data back. It reads clean. It "works" in the sense that it runs without errors. And it's wrong in a way that's easy to miss if nobody ever shows you why.
Why it breaks
AES doesn't take a password. It takes a key, and that key has to be an exact size — in my project, 256 bits (32 bytes).
A password is neither of those things. It's variable-length, human-chosen, and (unless your users are unusually disciplined) low-entropy. If you pad or truncate a password to force it into 32 bytes, you haven't created a strong key — you've created a shortcut for an attacker. They don't need to break AES. They just need to guess the password, since the password is the key in disguise.
This matters because passwords and keys have completely different jobs. A password needs to be memorable to a human. A key needs to be unpredictable to a computer. Treating them as interchangeable collapses two different security properties into one weak one.
The fix: derive the key, don't reuse the password
In crypto.py, the password never touches AES directly. It goes through a key derivation function first — specifically Argon2id:
from argon2.low_level import hash_secret_raw, Type
SALT_SIZE = 16
KEY_SIZE = 32
def derive_key(master_password: str, salt: bytes) -> bytes:
return hash_secret_raw(
secret=master_password.encode("utf-8"),
salt=salt,
time_cost=3,
memory_cost=65536,
parallelism=4,
hash_len=KEY_SIZE,
type=Type.ID,
)
Password goes in. A properly-sized, unpredictable 32-byte key comes out. That key is what actually gets handed to AES:
key = derive_key(password, salt)
nonce, ciphertext = encrypt(message, key)
The important part isn't just that the output is the right size — it's that Argon2id is deliberately expensive to compute. Guessing a password and running it through Argon2id to check if it produces the right key costs real time and memory, per guess. That cost is what turns "guess the password, get the key" from a fast attack into a slow, expensive one. (I go into exactly why that cost matters in the next post on Argon2id specifically.)
The takeaway
The password and the key are two different things, with two different jobs, and conflating them is one of the most common ways homegrown crypto quietly fails. Most tutorials that show AES.encrypt(password, data) aren't lying to you exactly — they're just skipping the step that actually matters.
If you're building anything that touches encryption, the question to ask isn't "what password should I use as my key" — it's "what's deriving my key, and how expensive is it to attack that derivation."
This is part of a public series where I'm documenting what I learn building out CryptoGraphy — next up: why salting stops rainbow table attacks completely, even against a leaked hash database.
Have you seen a real project use a password directly as a key? I'd genuinely like to hear the story — it's more common than it should be.
Top comments (0)