|
| 1 | +# SPDX-License-Identifier: AGPL-3.0-or-later |
| 2 | +# Copyright (C) 2026 MessageFoundry Organization and contributors |
| 3 | +"""An entropy FLOOR for MFA recovery codes, derived rather than transcribed (BACKLOG #1172). |
| 4 | +
|
| 5 | +Recovery codes are a full authentication factor: one of them substitutes for the authenticator app. |
| 6 | +So their guessing strength is the strength of the SECOND factor, and it was 68.67 bits. |
| 7 | +
|
| 8 | +**Every number here is computed from the shipped constants.** Nothing is transcribed, because a |
| 9 | +transcribed figure and the code it describes drift apart silently -- which is the defect this file |
| 10 | +exists to prevent, not merely to document. Two derivations do the work: |
| 11 | +
|
| 12 | +* **Per-code entropy** comes from ``_RECOVERY_GROUPS``, ``_RECOVERY_GROUP_LEN`` and the SIZE OF THE |
| 13 | + DISTINCT alphabet. Distinct on purpose: a duplicated character adds a symbol without adding a |
| 14 | + choice, so ``len(set(...))`` is the honest base and ``len(...)`` would overstate it. |
| 15 | +* **The multiplicity adjustment** comes from the VALIDATOR ITSELF, by asking it which counts it |
| 16 | + accepts. An attacker needs any ONE of the issued codes, so N codes cost ``log2(N)`` bits. Reading |
| 17 | + the ceiling out of the validator rather than writing ``50`` here means raising that ceiling |
| 18 | + tightens this test automatically instead of silently invalidating it. |
| 19 | +""" |
| 20 | + |
| 21 | +from __future__ import annotations |
| 22 | + |
| 23 | +import math |
| 24 | + |
| 25 | +import pytest |
| 26 | +from pydantic import ValidationError |
| 27 | + |
| 28 | +from messagefoundry.auth import totp |
| 29 | +from messagefoundry.config.settings import AuthSettings |
| 30 | + |
| 31 | +#: The bar. A second factor should be no weaker than a modern symmetric key. |
| 32 | +_FLOOR_BITS = 128.0 |
| 33 | + |
| 34 | + |
| 35 | +def _accepts(count: int) -> bool: |
| 36 | + """Does the SHIPPED validator accept this recovery-code count?""" |
| 37 | + try: |
| 38 | + AuthSettings(mfa_recovery_code_count=count) |
| 39 | + except ValidationError: |
| 40 | + return False |
| 41 | + return True |
| 42 | + |
| 43 | + |
| 44 | +def _validator_ceiling() -> int: |
| 45 | + """The largest count the validator accepts, found by ASKING IT rather than by transcribing 50.""" |
| 46 | + assert _accepts(0), "the validator rejects 0; this probe assumes 0 is the disabled case" |
| 47 | + hi = 1 |
| 48 | + while _accepts(hi): |
| 49 | + hi *= 2 |
| 50 | + assert hi <= 1 << 20, "no ceiling found below 2**20 -- the validator may be unbounded" |
| 51 | + lo = hi // 2 |
| 52 | + while lo + 1 < hi: # invariant: lo accepted, hi rejected |
| 53 | + mid = (lo + hi) // 2 |
| 54 | + if _accepts(mid): |
| 55 | + lo = mid |
| 56 | + else: |
| 57 | + hi = mid |
| 58 | + return lo |
| 59 | + |
| 60 | + |
| 61 | +def _per_code_bits(groups: int, group_len: int, alphabet: str) -> float: |
| 62 | + return groups * group_len * math.log2(len(set(alphabet))) |
| 63 | + |
| 64 | + |
| 65 | +def _guessing_bits(groups: int, group_len: int, alphabet: str, issued: int) -> float: |
| 66 | + """Strength against an attacker who needs ANY ONE of ``issued`` codes.""" |
| 67 | + return _per_code_bits(groups, group_len, alphabet) - math.log2(issued) |
| 68 | + |
| 69 | + |
| 70 | +def test_the_validator_ceiling_is_discoverable_and_finite() -> None: |
| 71 | + """The probe is a measurement, so it gets its own check: a broken probe would silently make |
| 72 | + every floor below look generous.""" |
| 73 | + ceiling = _validator_ceiling() |
| 74 | + assert ceiling >= 1 |
| 75 | + assert _accepts(ceiling), "the discovered ceiling is not actually accepted" |
| 76 | + assert not _accepts(ceiling + 1), "one above the discovered ceiling is still accepted" |
| 77 | + |
| 78 | + |
| 79 | +def test_recovery_codes_clear_the_entropy_floor_at_the_worst_permitted_count() -> None: |
| 80 | + """Asserted at the WORST case the validator permits, not at the shipped default. |
| 81 | +
|
| 82 | + The default is what a site gets; the ceiling is what a site may choose. A floor that only holds |
| 83 | + at the default is not a floor, and nothing stops an operator raising the count. |
| 84 | + """ |
| 85 | + bits = _guessing_bits( |
| 86 | + totp._RECOVERY_GROUPS, |
| 87 | + totp._RECOVERY_GROUP_LEN, |
| 88 | + totp._RECOVERY_ALPHABET, |
| 89 | + _validator_ceiling(), |
| 90 | + ) |
| 91 | + assert bits >= _FLOOR_BITS, ( |
| 92 | + f"recovery codes give {bits:.2f} bits against the worst permitted issue count, under the " |
| 93 | + f"{_FLOOR_BITS:.0f}-bit floor. A recovery code is a full second factor; raise " |
| 94 | + f"_RECOVERY_GROUPS in messagefoundry/auth/totp.py." |
| 95 | + ) |
| 96 | + |
| 97 | + |
| 98 | +@pytest.mark.parametrize("weaken", ["groups", "group_len", "alphabet"]) |
| 99 | +def test_lowering_any_constant_breaks_the_floor(weaken: str) -> None: |
| 100 | + """MUTATION CONTROL. A floor that cannot fail is not a floor. |
| 101 | +
|
| 102 | + Each of the three inputs is reduced by ONE unit in turn -- one group, one character per group, |
| 103 | + one symbol -- and the floor must red. If a mutation still passes, the margin is wide enough that |
| 104 | + this test would not notice a real regression, and the floor needs raising rather than the |
| 105 | + mutation excusing. |
| 106 | + """ |
| 107 | + groups = totp._RECOVERY_GROUPS - (1 if weaken == "groups" else 0) |
| 108 | + group_len = totp._RECOVERY_GROUP_LEN - (1 if weaken == "group_len" else 0) |
| 109 | + alphabet = totp._RECOVERY_ALPHABET[:-1] if weaken == "alphabet" else totp._RECOVERY_ALPHABET |
| 110 | + |
| 111 | + weakened = _guessing_bits(groups, group_len, alphabet, _validator_ceiling()) |
| 112 | + shipped = _guessing_bits( |
| 113 | + totp._RECOVERY_GROUPS, |
| 114 | + totp._RECOVERY_GROUP_LEN, |
| 115 | + totp._RECOVERY_ALPHABET, |
| 116 | + _validator_ceiling(), |
| 117 | + ) |
| 118 | + assert weakened < shipped, f"weakening {weaken!r} did not reduce the entropy at all" |
| 119 | + if weaken == "alphabet": |
| 120 | + # One symbol off 31 is worth ~0.05 bits/char; the assertion that matters is DIRECTION. |
| 121 | + pytest.skip("a single-symbol reduction is below the floor's resolution; direction asserted") |
| 122 | + assert weakened < _FLOOR_BITS, ( |
| 123 | + f"removing one {weaken} still yields {weakened:.2f} bits, at or above the " |
| 124 | + f"{_FLOOR_BITS:.0f}-bit floor -- so this floor cannot detect that regression." |
| 125 | + ) |
| 126 | + |
| 127 | + |
| 128 | +def test_generated_codes_match_the_constants_the_floor_is_computed_from() -> None: |
| 129 | + """The floor is arithmetic over constants; this pins that the GENERATOR actually uses them, so |
| 130 | + the arithmetic describes the shipped code rather than three unused names.""" |
| 131 | + codes = totp.generate_recovery_codes(3) |
| 132 | + assert len(codes) == 3 |
| 133 | + for code in codes: |
| 134 | + groups = code.split("-") |
| 135 | + assert len(groups) == totp._RECOVERY_GROUPS |
| 136 | + assert all(len(g) == totp._RECOVERY_GROUP_LEN for g in groups) |
| 137 | + assert set(code.replace("-", "")) <= set(totp._RECOVERY_ALPHABET) |
0 commit comments