"""password-entropy-calculator-nist-800-63b.py — honest entropy arithmetic for passwords and passphrases. What it does: computes the entropy in bits of a password *generation method* (not of a particular string), and turns that into the expected time to exhaust half the keyspace at a few stated guess rates. It deliberately does not "score" a user-typed password, because the entropy of a human-chosen string is unknowable from the string alone; it scores the process that produced it. It also shows how far a fixed guess budget (an online verifier that rate-limits, per NIST SP 800-63B) reaches into each keyspace. Inputs: none required. Optional: --length N --alphabet {digits,lower,alnum,printable} --words N --wordlist-size N (default 7776, the six-dice list size) --rates comma-separated guesses per second to tabulate Output: a table on stdout. Run: python code/password-entropy-calculator-nist-800-63b.py python code/password-entropy-calculator-nist-800-63b.py --length 16 --alphabet printable Standard library only (Python 3.9+). """ from __future__ import annotations import argparse import math ALPHABETS = {"digits": 10, "lower": 26, "alnum": 62, "printable": 95} DEFAULT_RATES = [10.0, 1_000.0, 1e10, 1e12] # online throttled, online unthrottled, offline fast hash, offline cluster def bits_random_chars(length: int, alphabet_size: int) -> float: return length * math.log2(alphabet_size) def bits_random_words(words: int, wordlist_size: int) -> float: return words * math.log2(wordlist_size) def human_time(seconds: float) -> str: units = [("s", 60), ("min", 60), ("h", 24), ("d", 365.25), ("y", None)] v = seconds for name, step in units: if step is None or v < step: return f"{v:.2e} {name}" if v >= 1e6 else f"{v:,.1f} {name}" v /= step return f"{v:.2e} y" def row(label: str, bits: float, rates: list[float]) -> str: half = 2 ** (bits - 1) # expected guesses to hit a uniformly random secret cells = [f"{human_time(half / r):>14}" for r in rates] return f"{label:<34}{bits:>7.1f} " + "".join(cells) def budget_reach(bits: float, budget: int) -> str: """What fraction of the keyspace a fixed number of guesses covers.""" frac = budget / (2 ** bits) return f"{frac:.2e}" if frac < 1e-3 else f"{frac:.1%}" def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("--length", type=int) ap.add_argument("--alphabet", choices=ALPHABETS, default="printable") ap.add_argument("--words", type=int) ap.add_argument("--wordlist-size", type=int, default=7776) ap.add_argument("--rates", default=",".join(str(r) for r in DEFAULT_RATES)) a = ap.parse_args() rates = [float(x) for x in a.rates.split(",")] print("Expected time to guess a UNIFORMLY RANDOM secret (half the keyspace) at each guess rate.") print("Rates: " + ", ".join(f"{r:g}/s" for r in rates)) print(f"{'generation method':<34}{'bits':>7} " + "".join(f"{f'{r:g}/s':>14}" for r in rates)) presets = [ ("8 random printable ASCII", bits_random_chars(8, 95)), ("8 random alphanumeric", bits_random_chars(8, 62)), ("12 random printable ASCII", bits_random_chars(12, 95)), ("15 random lowercase letters", bits_random_chars(15, 26)), ("16 random printable ASCII", bits_random_chars(16, 95)), ("4 random words (7776 list)", bits_random_words(4, 7776)), ("6 random words (7776 list)", bits_random_words(6, 7776)), ("6 random digits (a PIN)", bits_random_chars(6, 10)), ] if a.length: presets.append((f"{a.length} random {a.alphabet}", bits_random_chars(a.length, ALPHABETS[a.alphabet]))) if a.words: presets.append((f"{a.words} random words ({a.wordlist_size} list)", bits_random_words(a.words, a.wordlist_size))) for label, bits in presets: print(row(label, bits, rates)) print("\nHow far a fixed ONLINE guess budget reaches (fraction of keyspace covered):") for budget in (100, 10_000, 1_000_000): print(f" {budget:>9,} guesses: " + " ".join(f"{label.split(' (')[0]}={budget_reach(bits, budget)}" for label, bits in presets[:3])) print("\nNot computed: the entropy of a password a person chose. That depends on the chooser,") print("not the string, which is why NIST SP 800-63B replaced composition rules with length") print("minimums and a blocklist check. Use this table to pick a generator, not to grade a habit.") if __name__ == "__main__": main()