Skip to content

Commit 57087ce

Browse files
authored
Merge branch 'main' into claude/builder-1-1348-claim-liveness-v2
2 parents 4f242b6 + a69e830 commit 57087ce

6 files changed

Lines changed: 196 additions & 9 deletions

File tree

docs/BACKLOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9416,6 +9416,22 @@ Nothing here touches the TLS/FTPS context in the same module, which was already
94169416
> 🔢 **Re-scored 2026-08-20 -> P2.** Value **6/10** · Difficulty **3/10** · _quick win_. The concrete IDE defect is closed by cspNonce.ts and its negative test, so what remains is the recovery-code shortfall (totp.py:58-60, 31^15 or about 74.3 bits, ten issued by default at settings.py:1798) plus the cross-language inventory question the crypto gate still cannot answer (crypto_inventory_check.py:456-472 excludes ide/ by invariant rather than inventorying it). An operator can only work around the shortfall by setting the count to 0, which is the awkward-workaround band. _(was 6/10 · 4/10.)_
94179417
> Research: done 2026-08-20
94189418
>
9419+
> **PARTIAL 2026-08-25 -- THE PROPOSED BUILD SHIPPED (PR #603). THE ROW STAYS OPEN: the item's own
9420+
> closing act is a scorecard re-score, not this build, and that re-score is explicitly not run.**
9421+
> `_RECOVERY_GROUPS` went 3 -> 6 over the existing alphabet (148.63 bits, 142.98 after the
9422+
> multiplicity adjustment -- 15 bits of margin against the 128-bit floor rather than 59 bits short
9423+
> of it); the bare 26-character widening named below as a trap was NOT taken, matching this item's
9424+
> own reasoning. `_generate_policy_password` now draws 24 bytes rather than 16, so the
9425+
> `token_urlsafe` byte/character mismatch this research flagged as "safe by accident" no longer
9426+
> depends on accident. An entropy-floor regression test derives its bit count from the three
9427+
> shipped constants and the validator's own maximum count rather than transcribing either, with a
9428+
> mutation control run against the shipped constant (not just asserted): reverting
9429+
> `_RECOVERY_GROUPS` to 3 fails the new test, restored by byte-copy with the hash verified.
9430+
> **Still explicitly open, all named in this item's own proposed-work list and none touched by
9431+
> PR #603:** the ASVS 11.5.1 scorecard re-score itself; the scope-completeness adversarial pass this
9432+
> item's own caveat says was never run; recording the five zero-margin values its census already
9433+
> flags; and the cross-language randomness inventory arm.
9434+
>
94199435
> **Filed 2026-08-08 - not started. RESEARCH item: the goal is an HONEST pass, and "cannot honestly reach pass" is a valid finding.** ASVS **11.5.1** (L2) currently scores **partial**. The pinned verb sets a 128-bit floor on every non-guessable random value. TOTP recovery codes are CSPRNG-drawn but reach only about 74.3 bits, and the IDE extension mints its webview CSP nonces from `Math.random()`.
94209436
> Verdict: research
94219437
> Closing-act: scorecard-rescore

messagefoundry/auth/service.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -602,7 +602,11 @@ def _generate_policy_password(self) -> str:
602602
is held to the same bar operators are. ``token_urlsafe(n)`` yields ~1.33·n chars (so length is
603603
guaranteed ≥ ``min_length``); the loop covers the astronomically-unlikely breach/context hit
604604
or an opt-in character-class requirement a given token happens to miss."""
605-
length = max(16, self._policy.min_length)
605+
# 24 BYTES (192 bits), not 16. token_urlsafe's argument is a byte count, and the floor is
606+
# raised here rather than left at the policy minimum because min_length is a CHARACTER count
607+
# -- passing it as bytes happens to be safe but ties an entropy floor to a legibility knob an
608+
# operator may lower (BACKLOG #1172).
609+
length = max(24, self._policy.min_length)
606610
for _ in range(16):
607611
candidate = secrets.token_urlsafe(length)
608612
if not self._policy.violations(candidate):

messagefoundry/auth/totp.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,14 @@
5757
# Recovery codes: human-legible groups from an unambiguous alphabet (no 0/O/1/I/L confusion).
5858
_RECOVERY_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
5959
_RECOVERY_GROUP_LEN = 5
60-
_RECOVERY_GROUPS = 3 # e.g. "K7QF2-9DMNA-3XZP4" → ~74 bits of entropy
60+
# SIX groups, not three. Three gave 74.31 bits per code; against the 50-code ceiling
61+
# `[auth].mfa_recovery_code_count` permits, an attacker needs any ONE of them, so the guessing
62+
# strength is 74.31 - log2(50) = 68.67 bits -- 59 bits UNDER a 128-bit floor. Six gives 148.63,
63+
# i.e. 142.98 after the same adjustment, clearing the floor by 15 bits (BACKLOG #1172).
64+
# The floor is asserted in tests/test_recovery_code_entropy.py, DERIVED from these three constants
65+
# and from the validator's own ceiling, so none of them can drift away from it silently.
66+
# e.g. "K7QF2-9DMNA-3XZP4-B8VWR-6JTCH-2YQD5"
67+
_RECOVERY_GROUPS = 6
6168

6269

6370
def generate_secret() -> str:
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
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)

tests/test_totp.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,13 @@ def test_recovery_codes_count_format_and_uniqueness() -> None:
110110
assert len(set(codes)) == 10 # no collisions
111111
for code in codes:
112112
groups = code.split("-")
113-
assert len(groups) == 3
114-
assert all(len(g) == 5 for g in groups)
113+
# DERIVED from the constants, not transcribed. These three assertions carried the literals
114+
# 3, 5 and the alphabet until BACKLOG #1172 raised the group count -- at which point the
115+
# test failed for describing the old shape rather than for anything being wrong. A test that
116+
# transcribes a constant goes stale the first time the constant is right to change.
117+
# Whether the values are ADEQUATE is a separate question, asserted as an entropy floor in
118+
# tests/test_recovery_code_entropy.py.
119+
assert len(groups) == totp._RECOVERY_GROUPS
120+
assert all(len(g) == totp._RECOVERY_GROUP_LEN for g in groups)
115121
# Drawn from the unambiguous alphabet only (no 0/O/1/I/L).
116-
assert all(ch in "ABCDEFGHJKMNPQRSTUVWXYZ23456789" for g in groups for ch in g)
122+
assert all(ch in totp._RECOVERY_ALPHABET for g in groups for ch in g)

tests/test_worktree_prune_merged.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,27 @@
5050

5151
SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "worktree" / "prune-merged.ps1"
5252

53-
pytestmark = pytest.mark.skipif(
54-
shutil.which("pwsh") is None or os.name != "nt",
55-
reason="prune-merged.ps1 needs pwsh on Windows (Process.StartTime liveness fence)",
56-
)
53+
# TWO marks, and the timeout one is load-bearing on CI. Every subprocess wait in this file outlives
54+
# the leg's own `--timeout=120`: 300 s at the `run`/`run_text` helpers and 180 s at two tests. When a
55+
# wait outlives the pytest timeout, pytest-timeout dumps the stacks and calls `os._exit(1)`, which
56+
# kills the xdist worker before execnet can relay the dump -- so it surfaces as `worker 'gwN' crashed`
57+
# with NO traceback and reads like a native crash. Precedent and full diagnosis: a824cc749, which gave
58+
# tests/test_session_mail.py the same treatment for the same signature.
59+
#
60+
# MODULE-LEVEL, DEPARTING FROM THAT PRECEDENT'S PER-TEST SHAPE, and the reason is measured: 74 of the
61+
# 75 tests here reach the 300 s helpers, so 74 individual markers would be noise carrying no
62+
# information. The precedent's file had four slow tests among many, which is why per-test was right
63+
# THERE. Its PRINCIPLE -- the marker must outlive the subprocess wait -- is what transfers.
64+
#
65+
# 360 s is the precedent's headroom, not a guess: it used timeout(300) against a 240 s maximum wait,
66+
# i.e. 60 s of margin. This file's maximum wait is 300 s, so 360 keeps the same margin.
67+
pytestmark = [
68+
pytest.mark.skipif(
69+
shutil.which("pwsh") is None or os.name != "nt",
70+
reason="prune-merged.ps1 needs pwsh on Windows (Process.StartTime liveness fence)",
71+
),
72+
pytest.mark.timeout(360),
73+
]
5774

5875
# Metadata files the activity signal reads. Backdating these is how a fixture gets past the veto
5976
# without turning it off.

0 commit comments

Comments
 (0)