#!/usr/bin/env python3
"""password-hash-demo: why fast hashes are wrong for passwords, and what to use instead.

Usage:
    python password-hash-demo.py plain     same input, same hash
    python password-hash-demo.py salted    random salt per user, slow scrypt hash, verification
    python password-hash-demo.py speed     how many hashes per second each method manages

Standard library only (Python 3.8+). A defensive demo: it hashes and verifies, nothing else.
"""
import hashlib
import hmac
import os
import sys
import time

SAMPLE = "Summer2024!"  # a typical weak choice, used only to show how hashes behave
N, R, P = 2**17, 8, 1   # scrypt cost: 128 MiB of memory per hash (OWASP minimum configuration)
MAXMEM = 2**28


def plain():
    b = SAMPLE.encode()
    for user in ("alice", "bob"):
        print(f"{user:<6} MD5     {hashlib.md5(b).hexdigest()}")
        print(f"{user:<6} SHA-256 {hashlib.sha256(b).hexdigest()}")
    print("\nSame input, same hash. Anyone reading the database can see alice and bob chose the same")
    print("value, and one precomputed table of hashes works against every account.")


def store(pw: str) -> str:
    salt = os.urandom(16)
    h = hashlib.scrypt(pw.encode(), salt=salt, n=N, r=R, p=P, maxmem=MAXMEM, dklen=32)
    return f"scrypt${N}${R}${P}${salt.hex()}${h.hex()}"


def check(pw: str, record: str) -> bool:
    _, n, r, p, salt, h = record.split("$")
    got = hashlib.scrypt(pw.encode(), salt=bytes.fromhex(salt), n=int(n), r=int(r), p=int(p),
                         maxmem=MAXMEM, dklen=32)
    return hmac.compare_digest(got, bytes.fromhex(h))  # constant-time comparison


def salted():
    records = {user: store(SAMPLE) for user in ("alice", "bob")}
    for user, rec in records.items():
        print(f"{user:<6} {rec}")
    print()
    print("alice, correct input :", check(SAMPLE, records["alice"]))
    print("alice, wrong input   :", check(SAMPLE.lower(), records["alice"]))
    print("\nSame input, different stored values. Each record carries its own random 16-byte salt,")
    print("and the cost settings, so the format can be upgraded later without breaking old records.")


def rate(label, fn, seconds=1.0):
    n, start = 0, time.perf_counter()
    while time.perf_counter() - start < seconds:
        fn()
        n += 1
    per_second = n / (time.perf_counter() - start)
    print(f"{label:<34} {per_second:>16,.1f} per second")
    return per_second


def speed():
    b, salt = SAMPLE.encode(), bytes(16)
    fast = rate("MD5", lambda: hashlib.md5(b).digest())
    rate("SHA-256", lambda: hashlib.sha256(b).digest())
    rate("PBKDF2-SHA256, 600,000 rounds", lambda: hashlib.pbkdf2_hmac("sha256", b, salt, 600_000), 2.0)
    slow = rate("scrypt, N=131072, r=8, p=1", lambda: hashlib.scrypt(b, salt=salt, n=N, r=R, p=P,
                                                                       maxmem=MAXMEM, dklen=32), 2.0)
    print(f"\nOn this machine MD5 is {fast / slow:,.0f} times faster than scrypt.")
    print("A login takes a fraction of a second either way. Anyone who has to test guesses pays that")
    print("cost for every single guess.")


if __name__ == "__main__":
    modes = {"plain": plain, "salted": salted, "speed": speed}
    if len(sys.argv) != 2 or sys.argv[1] not in modes:
        print(__doc__)
        sys.exit(2)
    modes[sys.argv[1]]()