Cybersecurity-Projects/PROJECTS/foundations/password-manager/src/password_manager/crypto.py

429 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
©AngelaMos | 2026
crypto.py
All the cryptography for the password manager lives in this one file
"Cryptography" is just the math we use to scramble data so nobody can
read it without the right key. Two big ideas live in this file, and
together they protect the user's vault
────────────────────────────────────────────────────────────────────
Idea 1: Key Derivation — turning a password into a key
────────────────────────────────────────────────────────────────────
When the user types their master password, we cannot use it directly
as an encryption key. Two reasons
1. Passwords are short and predictable. A real key needs to be 256
bits of randomness, but "hunter2" is maybe 50 bits at best
2. If we used the password directly, an attacker who got their
hands on the encrypted vault could try millions of passwords
per second on a GPU until one worked
The fix is a Key Derivation Function (KDF). It takes the password
plus a random "salt" and runs them through a deliberately slow,
memory-hungry algorithm to produce a key. We use Argon2id — the
algorithm that won the 2015 Password Hashing Competition and is the
modern standard recommended by OWASP
Why slow on purpose? Because the legitimate user only does this ONCE
per session. An attacker has to do it for every password they guess.
Even a half-second delay multiplied by billions of guesses makes
brute-force impractical
────────────────────────────────────────────────────────────────────
Idea 2: Authenticated Encryption — locking and tamper-proofing
────────────────────────────────────────────────────────────────────
Once we have a key, we use it to encrypt the vault contents. We use
AES-256-GCM. AES is the encryption algorithm itself. GCM is a "mode"
that does two jobs at once
1. Confidentiality — scrambles the data so nobody can read it
without the key
2. Authenticity — stamps the result with a tamper-proof seal. If
anyone changes one byte of the ciphertext (or even the nonce),
decryption refuses and raises an error
Without GCM (or another authenticated mode), an attacker could flip
bits in the encrypted file in ways that flip bits in the decrypted
plaintext, even without knowing the key. GCM defeats that
────────────────────────────────────────────────────────────────────
What this file exposes
────────────────────────────────────────────────────────────────────
derive_key(...) — Argon2id: master password + salt → 32-byte key
generate_salt() — fresh random salt for a new vault
generate_nonce() — fresh random nonce for every encryption
encrypt(plaintext, key) — AES-256-GCM scramble
decrypt(ciphertext, ...)— AES-256-GCM unscramble (raises on tampering)
WrongPasswordError — exception raised when decryption fails
Connects to
vault.py — calls these functions to encrypt and decrypt vault data
constants.py — pulls KDF and cipher parameters from here
"""
# Standard library: cryptographically-secure random bytes — used
# here to generate fresh salts and nonces. NEVER use `random` for
# anything security-related.
import secrets
# Standard library: a decorator that turns a class into a small,
# immutable data record without writing `__init__` boilerplate.
from dataclasses import dataclass
# Third-party (argon2-cffi): the password-hashing function we use
# as our KDF (key-derivation function). `Type` selects the Argon2id
# variant; `hash_secret_raw` returns raw key bytes (no PHC string).
from argon2.low_level import Type, hash_secret_raw
# Third-party (cryptography): the specific exception raised when
# AES-GCM decryption fails its authentication check — we catch it
# and turn it into our own WrongPasswordError.
from cryptography.exceptions import InvalidTag
# Third-party (cryptography): authenticated symmetric encryption.
# AES-GCM gives us both confidentiality AND tamper detection in one.
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
# Local: pull every magic number from the constants module — sizes
# and Argon2 cost parameters all live there, never in this file.
from password_manager.constants import (
ARGON2_MEMORY_KIB,
ARGON2_PARALLELISM,
ARGON2_TIME_COST,
KEY_LENGTH_BYTES,
NONCE_LENGTH_BYTES,
SALT_LENGTH_BYTES,
)
# =============================================================================
# Custom exceptions — give meaningful names to the errors we raise
# =============================================================================
# Defining our own exception classes (instead of just raising plain
# `Exception`) lets callers handle them precisely. The CLI layer can
# say "if WrongPasswordError, print a friendly message" without
# accidentally catching unrelated errors
class CryptoError(Exception):
"""
Base class for every cryptography error we raise
Catching this catches every error from this module
"""
class WrongPasswordError(CryptoError):
"""
Raised when decryption fails
GCM authentication failure means one of three things, and we
cannot tell which without more context
1. The user typed the wrong master password
2. The vault file was tampered with
3. The vault file is corrupted
From the user's perspective, all three look the same: "I cannot
open this vault." So we raise a single error and let the CLI
show a single, honest message
"""
# =============================================================================
# KDF parameters — bundled so we can pass them around as one value
# =============================================================================
@dataclass(frozen = True, slots = True)
class KdfParameters:
"""
The Argon2id tuning knobs used to derive a key
`frozen=True` makes instances immutable — once created, they
cannot be modified. `slots=True` makes them lightweight in
memory. These two flags together give us a "value object" — a
bundle of fields that behaves like a single value
Why bundle them? Because the vault file stores these parameters
alongside the ciphertext. If we change the defaults later, old
vaults still decrypt correctly because they remember their own
parameters
Fields
------
time_cost
Number of passes Argon2 makes. Higher = slower = harder to crack
memory_cost
Memory in KiB. Higher = harder to attack with GPUs
parallelism
Threads Argon2 may use. Should match available cores
"""
time_cost: int
memory_cost: int
parallelism: int
@classmethod
def defaults(cls) -> "KdfParameters":
"""
Return the current recommended Argon2id parameters
The values live in constants.py and are informed by the OWASP
Password Storage Cheat Sheet. They are tuned for a single-user
local password manager (parallelism=4 instead of the
server-oriented parallelism=1) — see constants.py for the
full reasoning
"""
return cls(
time_cost = ARGON2_TIME_COST,
memory_cost = ARGON2_MEMORY_KIB,
parallelism = ARGON2_PARALLELISM,
)
# =============================================================================
# Random byte generation — salts and nonces
# =============================================================================
# Both salts and nonces need to be unpredictable. We use the `secrets`
# module from the Python standard library. It pulls bytes from the
# operating system's cryptographically secure random source
# (/dev/urandom on Linux, BCryptGenRandom on Windows)
#
# DO NOT use `random.randbytes()` for this. The `random` module is
# fast but predictable — given enough output, an attacker can predict
# future values. The `secrets` module is built for exactly this case
def generate_salt() -> bytes:
"""
Return SALT_LENGTH_BYTES of fresh, unpredictable random bytes
The salt is mixed in with the password before key derivation, so
that two users picking the same password get DIFFERENT keys. It
also defeats "rainbow table" attacks where an attacker
pre-computes a giant lookup table of common-password → key
The salt is NOT secret — we store it in plain text inside the
vault file. Its job is to be unique, not hidden
Returns
-------
bytes
A new random byte string of length SALT_LENGTH_BYTES (16 bytes)
"""
# secrets.token_bytes(n) returns n random bytes from the OS-level
# cryptographic random pool. Same call we would use to generate
# session tokens or API keys
return secrets.token_bytes(SALT_LENGTH_BYTES)
def generate_nonce() -> bytes:
"""
Return NONCE_LENGTH_BYTES of fresh, unpredictable random bytes
A nonce ("number used once") is generated FRESH for every single
encryption. Reusing a nonce with the same key in GCM mode is
catastrophic — it leaks plaintext to anyone watching. So we
generate a new one every time we save the vault
GCM allows nonces up to 2^32 messages safely with random 12-byte
nonces, which is far more vault saves than any human will ever
perform. So random generation is fine here
Returns
-------
bytes
A new random byte string of length NONCE_LENGTH_BYTES (12 bytes)
"""
return secrets.token_bytes(NONCE_LENGTH_BYTES)
# =============================================================================
# Key derivation — the slow part
# =============================================================================
def derive_key(
master_password: str,
salt: bytes,
parameters: KdfParameters | None = None,
) -> bytes:
"""
Turn a master password and a salt into a 32-byte encryption key
This is the SLOW step. On a modern laptop with the default
parameters, expect about 0.31 second per call. That is on
purpose — it is the cost an attacker must pay for every guess
The output is suitable as input to AES-256-GCM (which wants
exactly 32 bytes of key material)
Parameters
----------
master_password
What the user typed at the prompt, as a normal Python string
salt
The vault's salt (16 random bytes). Passing the SAME password
and SAME salt always produces the SAME key, which is how we
re-derive the key when the user comes back later
parameters
Argon2id tuning. Defaults to the project's recommended values
(see KdfParameters.defaults). The vault file stores whatever
was used so old vaults keep working when defaults change
Returns
-------
bytes
A 32-byte (256-bit) key, ready to hand to AES-256-GCM
Notes
-----
Argon2 needs BYTES, not strings. Cryptographic libraries always
work in raw bytes because that is the natural form for hashing
and arithmetic. We encode the password to UTF-8 bytes here
"""
# Refuse an empty password outright. Argon2 itself would happily
# derive a key from b"" + the salt, but the resulting "lock" has
# effectively no secret in it — anyone who steals the vault file
# can re-derive the same key from the public salt. We treat this
# as a programming error and raise loudly
if not master_password:
raise ValueError("master_password must not be empty")
# Default to recommended parameters if the caller did not specify.
# We do this lazily (`is None` check) instead of using
# `parameters = KdfParameters.defaults()` as a default argument,
# because Python evaluates default arguments ONCE at function
# definition time, which would make the default a singleton.
# That is fine for an immutable dataclass, but the lazy pattern
# is the safer general habit
if parameters is None:
parameters = KdfParameters.defaults()
# Strings live in memory as Unicode code points. Argon2 needs raw
# bytes. .encode("utf-8") is the standard way to convert
password_bytes = master_password.encode("utf-8")
# hash_secret_raw is the low-level Argon2 function. The argon2-cffi
# library also offers a high-level PasswordHasher that produces a
# formatted string — we do NOT want that here. We want the raw
# 32 bytes of key material to feed into AES
return hash_secret_raw(
secret = password_bytes,
salt = salt,
time_cost = parameters.time_cost,
memory_cost = parameters.memory_cost,
parallelism = parameters.parallelism,
hash_len = KEY_LENGTH_BYTES,
# Type.ID = Argon2id, the recommended variant
# Argon2id resists both side-channel attacks (Argon2i strength)
# and GPU-cracking attacks (Argon2d strength) — best of both
type = Type.ID,
)
# =============================================================================
# Symmetric encryption — AES-256-GCM
# =============================================================================
# "Symmetric" means the same key is used to encrypt AND decrypt
# (as opposed to public-key crypto where the keys differ)
#
# We use the high-level AESGCM class from the cryptography library.
# It bundles the cipher, the authentication tag, and constant-time
# tag verification into a single API. Using AES-GCM "by hand" with
# raw primitives is one of the easier ways to get cryptography wrong
def encrypt(plaintext: bytes, key: bytes) -> tuple[bytes, bytes]:
"""
Encrypt plaintext bytes with AES-256-GCM and return (nonce, ciphertext)
A new random nonce is generated for every call. The caller MUST
store the nonce alongside the ciphertext — without it, decryption
is impossible
The "ciphertext" returned actually contains both the encrypted
data AND a 16-byte authentication tag appended to the end. The
AESGCM library handles this concatenation automatically. The
tag is what makes the encryption tamper-evident
Parameters
----------
plaintext
Raw bytes to encrypt. Anything — JSON-encoded vault entries,
plain text, an image. The cipher does not care
key
The 32-byte key from derive_key. Wrong size raises ValueError
Returns
-------
tuple[bytes, bytes]
(nonce, ciphertext_with_tag). Both must be stored to allow
later decryption
"""
# Construct a cipher object bound to our key. AESGCM validates
# the key size: must be 16, 24, or 32 bytes (AES-128, 192, or 256)
cipher = AESGCM(key)
# Fresh nonce for every encryption — never reuse with the same key
nonce = generate_nonce()
# encrypt() returns ciphertext concatenated with the auth tag.
# `associated_data` is for data we want authenticated but NOT
# encrypted (like a packet header). We do not have any, so None
ciphertext = cipher.encrypt(
nonce = nonce,
data = plaintext,
associated_data = None,
)
return nonce, ciphertext
def decrypt(ciphertext: bytes, nonce: bytes, key: bytes) -> bytes:
"""
Decrypt ciphertext produced by encrypt() and verify it was not tampered with
If the key is wrong, the nonce is wrong, or anyone modified even
one byte of the ciphertext, the GCM authentication tag will not
validate and we raise WrongPasswordError. We do NOT distinguish
between "wrong password" and "tampered file" — they look the same
cryptographically and exposing the difference helps attackers
Parameters
----------
ciphertext
The output from encrypt() — encrypted data + 16-byte auth tag
nonce
The same nonce used during encryption
key
The 32-byte key derived from the master password
Returns
-------
bytes
The original plaintext bytes
Raises
------
WrongPasswordError
If decryption fails for any reason (wrong key, wrong nonce,
modified ciphertext, corruption)
"""
cipher = AESGCM(key)
# InvalidTag is the cryptography library's signal that the auth
# tag did not match. We catch it and re-raise as our own error
# so callers do not need to know about cryptography internals
try:
return cipher.decrypt(
nonce = nonce,
data = ciphertext,
associated_data = None,
)
except InvalidTag as exc:
# `from exc` preserves the original traceback for debugging
# while still raising our cleaner exception type
raise WrongPasswordError(
"Decryption failed: wrong master password or corrupted vault"
) from exc