75 lines
2.8 KiB
Python
75 lines
2.8 KiB
Python
|
|
"""Tests for furtka.passwd — stdlib-only password hashing.
|
||
|
|
|
||
|
|
The primary contract: hash/verify roundtrips cleanly, AND the verifier
|
||
|
|
accepts the werkzeug hash format that 26.11 / 26.12 boxes wrote to
|
||
|
|
``users.json``. Losing that backward compat would lock out existing
|
||
|
|
admins after a 26.13+ upgrade.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from furtka import passwd
|
||
|
|
|
||
|
|
|
||
|
|
def test_hash_roundtrip():
|
||
|
|
h = passwd.hash_password("hunter2")
|
||
|
|
assert passwd.verify_password("hunter2", h)
|
||
|
|
assert not passwd.verify_password("wrong", h)
|
||
|
|
|
||
|
|
|
||
|
|
def test_hash_is_salted():
|
||
|
|
# Two separate hashes of the same password must diverge.
|
||
|
|
a = passwd.hash_password("same-pw")
|
||
|
|
b = passwd.hash_password("same-pw")
|
||
|
|
assert a != b
|
||
|
|
assert passwd.verify_password("same-pw", a)
|
||
|
|
assert passwd.verify_password("same-pw", b)
|
||
|
|
|
||
|
|
|
||
|
|
def test_generated_hash_format():
|
||
|
|
# Shape is pbkdf2:<hash>:<iter>$<salt>$<hex>
|
||
|
|
h = passwd.hash_password("x")
|
||
|
|
parts = h.split("$", 2)
|
||
|
|
assert len(parts) == 3
|
||
|
|
method, salt, digest = parts
|
||
|
|
assert method.startswith("pbkdf2:sha256:")
|
||
|
|
assert salt
|
||
|
|
# digest is hex of pbkdf2_hmac sha256 → 64 hex chars
|
||
|
|
assert len(digest) == 64
|
||
|
|
assert all(c in "0123456789abcdef" for c in digest)
|
||
|
|
|
||
|
|
|
||
|
|
def test_verify_werkzeug_scrypt_hash():
|
||
|
|
"""Known werkzeug scrypt hash generated by 26.11 / 26.12 boxes.
|
||
|
|
|
||
|
|
Captured live off a .196 test VM after its auth bootstrap:
|
||
|
|
username=daniel, password=test-admin-pw1
|
||
|
|
Hash format: scrypt:32768:8:1$<salt>$<hex>
|
||
|
|
If this regresses, every existing box that upgraded via 26.11 and
|
||
|
|
set a password gets locked out on the next upgrade.
|
||
|
|
"""
|
||
|
|
known = (
|
||
|
|
"scrypt:32768:8:1$yWZUqJodowt9ieI1$"
|
||
|
|
"2d1059b3564da7492b4aa3c2be7fff6fef06085e5e1bfd52e897948c58246b7a"
|
||
|
|
"9603400355b7264f61c4436eba7bf8c947adec3d7a76be03b50efb4227e15a80"
|
||
|
|
)
|
||
|
|
assert passwd.verify_password("test-admin-pw1", known)
|
||
|
|
assert not passwd.verify_password("wrong-password", known)
|
||
|
|
|
||
|
|
|
||
|
|
def test_verify_rejects_malformed_hashes():
|
||
|
|
# Empty / missing delimiters / unknown method / bad int — all False.
|
||
|
|
assert not passwd.verify_password("x", "")
|
||
|
|
assert not passwd.verify_password("x", "nothingspecial")
|
||
|
|
assert not passwd.verify_password("x", "pbkdf2:sha256:600000") # no $salt$digest
|
||
|
|
assert not passwd.verify_password("x", "pbkdf2$salt$digest") # missing hash + iter
|
||
|
|
assert not passwd.verify_password("x", "bcrypt:12$salt$digest") # unsupported algo
|
||
|
|
assert not passwd.verify_password("x", "pbkdf2:sha256:abc$salt$digest") # bad iter int
|
||
|
|
|
||
|
|
|
||
|
|
def test_verify_rejects_nonstring_inputs():
|
||
|
|
# Defensive: users.json can be corrupted or have nulls.
|
||
|
|
assert not passwd.verify_password(None, "pbkdf2:sha256:1000$salt$digest") # type: ignore[arg-type]
|
||
|
|
assert not passwd.verify_password("x", None) # type: ignore[arg-type]
|
||
|
|
assert not passwd.verify_password("x", 12345) # type: ignore[arg-type]
|