Cybersecurity-Projects/PROJECTS/foundations/password-manager/tests/test_crypto.py

416 lines
17 KiB
Python

"""
©AngelaMos | 2026
test_crypto.py
Tests for the crypto module — KDF (Argon2id) and authenticated
encryption (AES-256-GCM)
These tests verify the cryptographic primitives in isolation. They
do not touch disk and they do not depend on the vault module. Each
test exercises ONE property of ONE function, so a failure points
directly at the broken piece
────────────────────────────────────────────────────────────────────
What is "the crypto" doing that we need to verify?
────────────────────────────────────────────────────────────────────
Two cryptographic ideas live in crypto.py
1. Key derivation — turning a master password into a 32-byte AES
key. The KDF must be
deterministic so the user can come back tomorrow and
re-derive the SAME key from their password
salt-sensitive so two users with the same password get
DIFFERENT keys (defeats rainbow tables)
slow so brute-force is expensive
We test all three properties below
2. Authenticated encryption — encrypting bytes with AES-256-GCM in
a way that ANY tampering (wrong key, modified ciphertext, or
modified nonce) causes decryption to refuse. GCM gives us this
for free; we verify each failure mode in turn
────────────────────────────────────────────────────────────────────
Why TEST_KDF_PARAMETERS exists
────────────────────────────────────────────────────────────────────
Production Argon2 parameters are expensive on purpose (~300 ms per
derivation). Running 20 tests at that cost would mean a 6-second
test suite, which discourages running tests during development.
TEST_KDF_PARAMETERS (in conftest.py) uses the absolute minimum
Argon2-acceptable values — derivation finishes in milliseconds while
still exercising the same code path. Crypto correctness does not
depend on parameter strength
────────────────────────────────────────────────────────────────────
A short pytest primer for this file
────────────────────────────────────────────────────────────────────
`assert <expr>`
pytest fails the test if <expr> is falsy
`pytest.raises(SomeError)`
Context manager that requires the block to raise SomeError. This
is how we verify "refuses bad input" cases — wrong password,
tampered ciphertext, modified nonce
`bytearray(...)` vs `bytes(...)`
Python's bytes is IMMUTABLE; you cannot do `b[0] = 0x42`. To
flip a bit for a tampering test, copy into a bytearray (mutable),
mutate, then cast back to bytes when handing it to a function
that expects bytes
"""
# Third-party: the test runner. Used for `pytest.raises` and the
# `@pytest.mark.parametrize` decorator below.
import pytest
# Local: every crypto helper under test — the dataclass, the error
# type, the encrypt/decrypt pair, and the random-bytes helpers.
from password_manager.crypto import (
KdfParameters,
WrongPasswordError,
decrypt,
derive_key,
encrypt,
generate_nonce,
generate_salt,
)
# Local: byte-length constants — we assert outputs match these
# without ever hard-coding 32 or 16 inside this file.
from password_manager.constants import (
KEY_LENGTH_BYTES,
NONCE_LENGTH_BYTES,
SALT_LENGTH_BYTES,
)
# Local: the fast KDF parameters defined in conftest. Importing
# directly (rather than via a fixture) keeps test bodies short.
from tests.conftest import TEST_KDF_PARAMETERS
# =============================================================================
# Random byte generation — salts and nonces must be the right size + unpredictable
# =============================================================================
def test_generate_salt_returns_correct_length() -> None:
"""
Verify generate_salt() produces exactly SALT_LENGTH_BYTES of output
The salt length is part of our on-disk format contract — change
it without updating SALT_LENGTH_BYTES and every existing vault
would fail to decrypt. The most basic invariant to pin down
"""
salt = generate_salt()
assert len(salt) == SALT_LENGTH_BYTES
def test_generate_salt_returns_different_values_on_each_call() -> None:
"""
Verify generate_salt() yields a fresh random value every time
The whole point of a salt is that two users with the same
password get different keys. If generate_salt() returned the same
bytes every call, that property collapses
We collect 50 salts into a set; if the source is random, all 50
will be unique with overwhelming probability (16 random bytes =
128 bits of entropy, collisions are astronomically unlikely).
A failure here means `secrets.token_bytes` is broken or has been
replaced with a non-secure source
"""
salts = {generate_salt() for _ in range(50)}
# 50 unique 16-byte values is overwhelmingly likely if the source
# is truly random. A failure here means token_bytes is broken
assert len(salts) == 50
def test_generate_nonce_returns_correct_length() -> None:
"""
Verify generate_nonce() produces exactly NONCE_LENGTH_BYTES of output
AES-GCM's recommended nonce length is 12 bytes. Other lengths are
technically valid but degrade security guarantees. We pin to
NONCE_LENGTH_BYTES (12) and verify here so an accidental refactor
to 16 or 8 would fail this test immediately
"""
nonce = generate_nonce()
assert len(nonce) == NONCE_LENGTH_BYTES
def test_generate_nonce_returns_different_values_on_each_call() -> None:
"""
Verify nonces are fresh on every call — the most security-critical property in the file
Nonce reuse in GCM is CATASTROPHIC. Two messages encrypted with
the same (key, nonce) pair leak the XOR of the plaintexts to
anyone watching, and let an attacker forge messages. So
`generate_nonce` is the most security-critical function here,
and "no two values are ever the same" is the property that matters
Same 50-into-a-set check as for salts. Different values = working
"""
nonces = {generate_nonce() for _ in range(50)}
assert len(nonces) == 50
# =============================================================================
# Key derivation — Argon2id properties
# =============================================================================
def test_derive_key_returns_correct_length() -> None:
"""
Verify derive_key produces exactly KEY_LENGTH_BYTES of output
KEY_LENGTH_BYTES is 32 — what AES-256-GCM expects. The Argon2
library lets us request any output length; if we accidentally
asked for 16 we would get an AES-128 vault, and if we asked for
64 the cipher would refuse to construct at all
"""
key = derive_key(
"password",
generate_salt(),
TEST_KDF_PARAMETERS,
)
assert len(key) == KEY_LENGTH_BYTES
def test_derive_key_is_deterministic() -> None:
"""
Verify derive_key is deterministic — same inputs always produce the same key
This is THE property that makes the password manager work. When
the user types their master password tomorrow, we must derive
the EXACT same key we used to encrypt the vault yesterday. If
Argon2 were non-deterministic (say, mixed in a timestamp
internally), the user could never unlock their vault again
Notice we generate the salt ONCE and reuse it for both
derivations — that is what isolates the "same inputs" condition
"""
salt = generate_salt()
key_a = derive_key("hunter2", salt, TEST_KDF_PARAMETERS)
key_b = derive_key("hunter2", salt, TEST_KDF_PARAMETERS)
assert key_a == key_b
def test_derive_key_different_passwords_yield_different_keys() -> None:
"""
Verify changing the password (with the salt held constant) yields a different key
This is the basic "the password matters" property. If derive_key
produced the same key for "password-a" and "password-b" with the
same salt, the function would be ignoring its first argument —
which would mean every vault could be unlocked with any password.
Catastrophic
"""
salt = generate_salt()
key_a = derive_key("password-a", salt, TEST_KDF_PARAMETERS)
key_b = derive_key("password-b", salt, TEST_KDF_PARAMETERS)
assert key_a != key_b
def test_derive_key_different_salts_yield_different_keys() -> None:
"""
Verify changing the salt (with the password held constant) yields a different key
The salt is what defeats rainbow tables — two users with the same
password must end up with different keys because their salts
differ. Without this property, an attacker who steals 10 million
encrypted vaults could pre-compute keys for the top 1000 common
passwords and instantly unlock every vault belonging to anyone
who picked one
"""
key_a = derive_key("hunter2", generate_salt(), TEST_KDF_PARAMETERS)
key_b = derive_key("hunter2", generate_salt(), TEST_KDF_PARAMETERS)
assert key_a != key_b
def test_kdf_parameters_defaults_are_immutable() -> None:
"""
Verify KdfParameters instances reject attribute assignment after construction
`frozen=True` on the dataclass is what makes this work. Trying
to assign to a frozen instance raises AttributeError at runtime.
We confirm here so a future refactor that drops `frozen=True`
would fail this test immediately
The `# type: ignore[misc]` comment tells mypy "yes, we KNOW this
line is illegal — that is the whole point of the test." Without
the comment, type-checking would refuse before pytest could even
run the test
"""
params = KdfParameters.defaults()
with pytest.raises(AttributeError):
params.time_cost = 999 # type: ignore[misc]
def test_derive_key_rejects_empty_password() -> None:
"""
Verify derive_key refuses an empty master password
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. derive_key refuses outright as a
programming-error floor, and we verify here that the refusal
takes the form of a ValueError
"""
with pytest.raises(ValueError):
derive_key("", generate_salt(), TEST_KDF_PARAMETERS)
# =============================================================================
# Encrypt / decrypt round-trip — the basic AES-GCM contract
# =============================================================================
def test_encrypt_decrypt_round_trip() -> None:
"""
Verify decrypt(encrypt(x)) returns the original x
The most basic encryption test imaginable: encrypt some bytes,
decrypt them, get the same bytes back. If this ever fails, the
encryption module is fundamentally broken and nothing else in
the test suite will be meaningful
Notice the structure: we derive a key from a password (same flow
as production), encrypt, decrypt, compare. We use a real
password+salt rather than hard-coded key bytes so the full
derive-then-encrypt path is exercised
"""
salt = generate_salt()
key = derive_key("master", salt, TEST_KDF_PARAMETERS)
plaintext = b"the quick brown fox jumps over the lazy dog"
nonce, ciphertext = encrypt(plaintext, key)
recovered = decrypt(ciphertext, nonce, key)
assert recovered == plaintext
def test_encrypt_produces_fresh_nonce_each_call() -> None:
"""
Verify two calls to encrypt() with the same plaintext+key still produce different output
Each encrypt() call internally calls generate_nonce(). Because
the nonce is fresh, the ciphertext for the same plaintext+key
WILL differ across calls — both the nonce bytes and the
ciphertext bytes
If a future refactor accidentally cached the nonce (or used a
counter that reset), this test would catch it. Nonce reuse with
the same key in GCM is one of the all-time worst cryptographic
mistakes — see "Nonce Disrespecting Adversaries" if you want a
deep dive
"""
salt = generate_salt()
key = derive_key("master", salt, TEST_KDF_PARAMETERS)
plaintext = b"hello"
nonce1, ct1 = encrypt(plaintext, key)
nonce2, ct2 = encrypt(plaintext, key)
# Same plaintext + same key but different nonce → different ciphertext
assert nonce1 != nonce2
assert ct1 != ct2
def test_encrypt_handles_empty_plaintext() -> None:
"""
Verify encrypt() handles an empty bytes object cleanly
The empty plaintext is a real edge case: a freshly-initialized
vault encrypts an empty entries dict, which JSON-serializes to
b"{}" — but if a user deletes every entry, we end up in the
same place. Pinning down b"" specifically here guards against
off-by-one bugs in length-prefix code or similar
"""
salt = generate_salt()
key = derive_key("master", salt, TEST_KDF_PARAMETERS)
nonce, ciphertext = encrypt(b"", key)
assert decrypt(ciphertext, nonce, key) == b""
# =============================================================================
# Decryption refuses tampered or wrong-key input — GCM authentication
# =============================================================================
def test_decrypt_with_wrong_key_raises_wrong_password_error() -> None:
"""
Verify decrypt() refuses when the key does not match the encryption key
This is the "wrong master password" path. Encrypt with one
derived key, attempt to decrypt with a DIFFERENT derived key,
GCM refuses because the authentication tag will not validate.
We re-raise that low-level failure as WrongPasswordError (in
crypto.py) so the CLI can show the user a clean message instead
of leaking cryptography-library internals
This is the primary security property of the entire password
manager: only the correct master password can unlock the vault
"""
salt = generate_salt()
correct_key = derive_key("correct", salt, TEST_KDF_PARAMETERS)
wrong_key = derive_key("wrong", salt, TEST_KDF_PARAMETERS)
nonce, ciphertext = encrypt(b"secret", correct_key)
with pytest.raises(WrongPasswordError):
decrypt(ciphertext, nonce, wrong_key)
def test_decrypt_with_modified_ciphertext_raises() -> None:
"""
Verify flipping any byte of the ciphertext causes decryption to fail
This is GCM's "tamper-evidence" property. The authentication tag
appended to the ciphertext covers every byte of it — flip one
bit and the tag no longer matches, GCM refuses. We do NOT get a
quietly-mangled plaintext back; we get an exception
The bit-flip we perform — `tampered[middle] ^= 0x01` — uses XOR
against 0x01 to flip the lowest bit of the byte at the middle of
the ciphertext. The exact bit does not matter; ANY change to ANY
byte of the ciphertext must trip the auth tag
"""
salt = generate_salt()
key = derive_key("master", salt, TEST_KDF_PARAMETERS)
nonce, ciphertext = encrypt(b"important data", key)
# Flip one bit somewhere in the middle. We need a mutable copy
# because Python's `bytes` is immutable — `b[0] = 0x42` would
# raise. `bytearray` is the mutable cousin. After flipping we
# cast back to bytes for the decrypt call (which expects bytes,
# not bytearray, as a matter of type-strict API discipline)
middle = len(ciphertext) // 2
tampered = bytearray(ciphertext)
tampered[middle] ^= 0x01
tampered_bytes = bytes(tampered)
with pytest.raises(WrongPasswordError):
decrypt(tampered_bytes, nonce, key)
def test_decrypt_with_modified_nonce_raises() -> None:
"""
Verify modifying the nonce also causes decryption to fail
Many beginners assume only the ciphertext is authenticated. In
fact GCM authenticates the (nonce, ciphertext) pair together —
if an attacker swaps the nonce for some other valid-looking
value, the tag still will not match and decryption refuses
Flipping every bit of the first nonce byte with `^= 0xFF` is a
high-confidence way to produce a definitively-different nonce
(a tampered byte may collide with the original; flipping every
bit guarantees it does not)
"""
salt = generate_salt()
key = derive_key("master", salt, TEST_KDF_PARAMETERS)
nonce, ciphertext = encrypt(b"important data", key)
bad_nonce = bytearray(nonce)
bad_nonce[0] ^= 0xFF # flip every bit of the first byte
with pytest.raises(WrongPasswordError):
decrypt(ciphertext, bytes(bad_nonce), key)