From 6ea9034883fc470bcd70b15c54b771a428477014 Mon Sep 17 00:00:00 2001 From: kraxo Date: Sun, 16 Aug 2026 14:11:52 +0200 Subject: [PATCH 1/2] fix(anchors): verify_rfc3161 keeps the never-raise rule it prescribes to others MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `register_anchor_type` documents that a verifier "MUST be fail-closed … never raise for an ordinary bad proof". This first-party implementation did not hold its own rule: `frozen` and `rp_trust` are consumed with `.get(...)`, so a non-dict raised a raw `AttributeError` out of a verdict-returning surface. Recorded by the self-gate run as F3 on 2026-07-31. Re-measured against main today, sixteen days later, and it still reproduced: rp_trust=123 -> AttributeError: 'int' object has no attribute 'get' frozen=123 -> AttributeError: 'int' object has no attribute 'get' WHY IT SURVIVED SO LONG, and this is the part worth keeping. Two axes of the never-raise family property were blind to it at once: MODULE axis -- `anchors_rfc3161` was not in `_MODULES`, so the property never entered the module at all. Closed separately by the coverage guard in PR #141. ARGUMENT axis -- the property fuzzes only the PRIMARY parameter, and both affected arguments are keyword-only. Even inside the population the property does not reach them. That is the finding the self-gate recorded as F2 and it stays OPEN. So this test file covers this one surface directly instead of pretending the general sweep does. A fix that relied on the sweep would be closing the instance while believing it closed the class. THE FLOOR, NOT A WIDER EXCEPT, for the same reason evalcard and prereg carry theirs (L1-01): an `except AttributeError` would close this one shape, let the next type-confusion sibling through, and swallow a genuine internal AttributeError on top. `BundleFormatError` is in the family's accepted set, so the surface still DECIDES instead of crashing. MEASUREMENT PRECONDITION, learned the hard way today: without `proofbundle[anchors]` the function returns at its optional-import guard BEFORE these lines, and a probe reads green for a reason unrelated to the defence it names -- the class the fixture manifest calls `vacuous_seam_passes_for_a_reason_other_than_the_defence_it_names`. The tests therefore SKIP honestly when the extra is absent rather than pass vacuously. BIDIRECTIONAL EVIDENCE. With the floor: 5 green. Without it (temporarily removed): FAILED failures=1 errors=8, and the failure message reproduces the original finding verbatim -- "frozen=123 still raises a raw AttributeError: 'int' object has no attribute 'get'". Two of the five tests exist for the other direction: the documented `rp_trust=None` default and a valid mapping must still reach a verdict, so the floor cannot have broken the surface it protects. full suite Ran 2035 tests OK skipped=10 · ruff clean · mypy: no issues in 63 source files. HONEST SEVERITY, unchanged from the finding: this surface is not exported at package level and `verify_anchor` wraps every verifier in `except Exception`, so nothing leaked over the public path. The contradiction was the point -- the project's own implementation not keeping the rule it prescribes to third-party authors. Co-Authored-By: Claude Opus 5 (1M context) --- src/proofbundle/anchors_rfc3161.py | 27 ++++++ tests/test_anchors_rfc3161_type_floor.py | 103 +++++++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 tests/test_anchors_rfc3161_type_floor.py diff --git a/src/proofbundle/anchors_rfc3161.py b/src/proofbundle/anchors_rfc3161.py index 4aa476ba..beeb1cef 100644 --- a/src/proofbundle/anchors_rfc3161.py +++ b/src/proofbundle/anchors_rfc3161.py @@ -46,6 +46,33 @@ def verify_rfc3161(proof: bytes, canonical_root: bytes, *, frozen: dict, now: Op stricter-only pin via ``frozen.policyOid``; either way the token's ``TSTInfo.policy`` MUST match or it fails closed. """ + # TYPE FLOOR on the trust-config arguments (self-gate finding F3, 2026-07-31; still reproducing on + # main sixteen days later, re-measured 2026-08-16). `register_anchor_type` prescribes to THIRD-PARTY + # authors that a verifier "MUST be fail-closed … never raise for an ordinary bad proof". This + # first-party implementation did not hold its own rule: `frozen` and `rp_trust` are consumed with + # `.get(...)` below, so a non-dict raised a raw `AttributeError` out of a verdict-returning surface. + # + # Measured before the fix, with `[anchors]` installed so the code actually reaches these lines (without + # it the function returns at the import guard above and the probe is green for a reason that has + # nothing to do with the defence it names): + # rp_trust=123 -> AttributeError: 'int' object has no attribute 'get' + # frozen=123 -> AttributeError: 'int' object has no attribute 'get' + # + # Why the floor and not a wider except: the same reason the evalcard/prereg floors (L1-01) exist. An + # `except AttributeError` would close this one shape and let the next type-confusion sibling through, + # and it would swallow a genuine internal AttributeError too. `BundleFormatError` is in the family's + # accepted, fail-closed set, so the surface still DECIDES instead of crashing. + # + # Honest severity, unchanged from the finding: this surface is not exported at package level and + # `verify_anchor` wraps every verifier in `except Exception`, so nothing leaked over the public path. + # The contradiction is what mattered — the project's own implementation not keeping the rule it + # prescribes to others. + if frozen is not None and not isinstance(frozen, dict): + from .errors import BundleFormatError as _BFE # noqa: PLC0415 + raise _BFE(f"frozen must be a mapping, got {type(frozen).__name__} (fail-closed)") + if rp_trust is not None and not isinstance(rp_trust, dict): + from .errors import BundleFormatError as _BFE # noqa: PLC0415 + raise _BFE(f"rp_trust must be a mapping, got {type(rp_trust).__name__} (fail-closed)") try: import rfc3161_client as tsp # noqa: PLC0415 except ImportError: diff --git a/tests/test_anchors_rfc3161_type_floor.py b/tests/test_anchors_rfc3161_type_floor.py new file mode 100644 index 00000000..e5a84bcd --- /dev/null +++ b/tests/test_anchors_rfc3161_type_floor.py @@ -0,0 +1,103 @@ +"""`verify_rfc3161` must hold the never-raise rule it prescribes to third-party authors. + +`register_anchor_type` documents that a verifier "MUST be fail-closed … never raise for an ordinary +bad proof". The self-gate run of 2026-07-31 recorded as F3 that this FIRST-PARTY implementation did +not hold it: `frozen` and `rp_trust` are consumed with `.get(...)`, so a non-dict raised a raw +`AttributeError` out of a verdict-returning surface. Re-measured against `main` on 2026-08-16, sixteen +days later, it still reproduced. + +WHY THE FAMILY PROPERTY NEVER CAUGHT IT, and why this file has to exist separately. Two axes of the +same instrument were blind here: + + * the MODULE axis — `anchors_rfc3161` was outside `_MODULES`, so the property never entered it. + That is closed by tests/test_never_raise_population_guard.py. + * the ARGUMENT axis — the property fuzzes only the PRIMARY parameter. `frozen` and `rp_trust` are + keyword-only, so even inside the population the property does not reach them. That axis is the + finding the self-gate recorded as F2 and it is NOT closed; this file covers this one surface + directly rather than pretending the general sweep does. + +MEASUREMENT PRECONDITION, learned the hard way on 2026-08-16. Without `rfc3161_client` installed the +function returns at its optional-import guard BEFORE reaching the lines under test, and a probe reads +green for a reason that has nothing to do with the defence it names — the exact class the fixture +manifest calls `vacuous_seam_passes_for_a_reason_other_than_the_defence_it_names`. These tests +therefore SKIP honestly when the extra is absent instead of passing vacuously. +""" +from __future__ import annotations + +import unittest + +from proofbundle.anchors_rfc3161 import verify_rfc3161 +from proofbundle.errors import BundleFormatError + + +def _anchors_extra_present() -> bool: + try: + import rfc3161_client # noqa: F401, PLC0415 + return True + except ImportError: + return False + + +@unittest.skipUnless(_anchors_extra_present(), + "needs proofbundle[anchors]: without it the function returns at its import guard " + "before the lines under test, and a pass here would be vacuous") +class TestRfc3161TrustConfigTypeFloor(unittest.TestCase): + """Every hostile TYPE on the trust-config arguments must yield a typed error, never a raw one.""" + + HOSTILE = [None, 123, 1.5, True, b"bytes", "a string", ["a", "list"], ("t", "u"), object()] + + def test_non_mapping_frozen_is_a_typed_error(self): + for bad in self.HOSTILE: + if bad is None: + continue # None is the documented "absent" case, handled below + with self.subTest(bad=type(bad).__name__): + with self.assertRaises(BundleFormatError): + verify_rfc3161(b"", b"", frozen=bad) + + def test_non_mapping_rp_trust_is_a_typed_error(self): + for bad in self.HOSTILE: + if bad is None: + continue # None is the documented default + with self.subTest(bad=type(bad).__name__): + with self.assertRaises(BundleFormatError): + verify_rfc3161(b"", b"", frozen={}, rp_trust=bad) + + def test_no_raw_attributeerror_survives(self): + """The regression this closes, stated as the shape it had rather than as a description. + + `AttributeError` is in the property's `_FORBIDDEN` set: it is a type-confusion crash signature, + which is precisely what a verdict-returning surface must not emit. + """ + for arg, bad in (("frozen", 123), ("rp_trust", 123)): + with self.subTest(arg=arg): + kwargs = {"frozen": {}, "rp_trust": None} + kwargs[arg] = bad + try: + verify_rfc3161(b"", b"", **kwargs) + except BundleFormatError: + pass # typed, fail-closed: accepted + except AttributeError as exc: # the regression + self.fail(f"{arg}={bad!r} still raises a raw AttributeError: {exc}") + + def test_the_documented_defaults_still_reach_a_verdict(self): + """Bidirectional validation: the floor must not turn a legitimate call into an error. + + `rp_trust=None` is the documented default and `frozen={}` is a legitimate empty block. Both must + still return the `needs_rp_trust` verdict rather than raise — otherwise the floor has broken the + surface it was meant to protect. + """ + res = verify_rfc3161(b"", b"", frozen={}, rp_trust=None) + self.assertIsInstance(res, dict) + self.assertIs(res.get("ok"), False) + self.assertEqual(res.get("status"), "needs_rp_trust") + + def test_a_valid_mapping_is_not_rejected_by_the_floor(self): + """A dict passes the floor and the function proceeds to its real work (and fails closed there).""" + res = verify_rfc3161(b"", b"", frozen={"rootCertsDerB64": []}, + rp_trust={"trusted_tsa_roots": []}) + self.assertIsInstance(res, dict) + self.assertIs(res.get("ok"), False) + + +if __name__ == "__main__": # pragma: no cover + unittest.main() From 2d25e0f7aa74d9192c8d9714203a7fe6cceff241 Mon Sep 17 00:00:00 2001 From: kraxo Date: Sun, 16 Aug 2026 14:20:06 +0200 Subject: [PATCH 2/2] fix(anchors): the floor tests the interface, not the implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The counter-read rejected the previous commit on my own doubt #2, which I had written down and then not acted on -- the third time today that the same shape came back to me from outside. `isinstance(x, dict)` programs to an implementation. The following logic calls `.get(...)`, so the question is whether the argument is a Mapping, not whether it is exactly a dict. `MappingProxyType`, `OrderedDict` and any dict-like object were being refused for no reason. MEASURED before changing it, so one wrong check would not be swapped for another: every use of `frozen` and `rp` in this function is `.get(...)` -- six call sites, no subscript, no dict-only method, no mutation. `.get` belongs to the `Mapping` protocol, so `collections.abc.Mapping` is not merely more permissive here, it is exactly the right predicate. A floor that rejects valid input is a defect of its own. It is quieter than the crash it replaced, which makes it worse to find, not better. BIDIRECTIONAL EVIDENCE for the correction itself: with `Mapping` 6 tests green; with the old `dict` check restored, FAILED errors=1 -- "BundleFormatError: frozen must be a mapping, got mappingproxy (fail-closed)". The new test therefore discriminates; without that counter-check it would be a test that measures nothing. full suite Ran 2036 tests OK skipped=10 · ruff clean · mypy: no issues in 63 source files. Co-Authored-By: Claude Opus 5 (1M context) --- src/proofbundle/anchors_rfc3161.py | 11 +++++++++-- tests/test_anchors_rfc3161_type_floor.py | 21 +++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/proofbundle/anchors_rfc3161.py b/src/proofbundle/anchors_rfc3161.py index beeb1cef..a66f0b79 100644 --- a/src/proofbundle/anchors_rfc3161.py +++ b/src/proofbundle/anchors_rfc3161.py @@ -67,10 +67,17 @@ def verify_rfc3161(proof: bytes, canonical_root: bytes, *, frozen: dict, now: Op # `verify_anchor` wraps every verifier in `except Exception`, so nothing leaked over the public path. # The contradiction is what mattered — the project's own implementation not keeping the rule it # prescribes to others. - if frozen is not None and not isinstance(frozen, dict): + # The check is `Mapping`, not `dict`, and the difference is not pedantry. The counter-read rejected + # the first attempt for testing the IMPLEMENTATION instead of the INTERFACE, and measuring settled it: + # every use of these two arguments in this function is `.get(...)` — six call sites, no subscript, no + # dict-only method, no mutation. `.get` is part of the `Mapping` protocol, so `MappingProxyType`, + # `OrderedDict` and any dict-like object work here and were being rejected for no reason. A floor that + # refuses valid input is a defect of its own, just a quieter one than the crash it replaced. + from collections.abc import Mapping as _Mapping # noqa: PLC0415 + if frozen is not None and not isinstance(frozen, _Mapping): from .errors import BundleFormatError as _BFE # noqa: PLC0415 raise _BFE(f"frozen must be a mapping, got {type(frozen).__name__} (fail-closed)") - if rp_trust is not None and not isinstance(rp_trust, dict): + if rp_trust is not None and not isinstance(rp_trust, _Mapping): from .errors import BundleFormatError as _BFE # noqa: PLC0415 raise _BFE(f"rp_trust must be a mapping, got {type(rp_trust).__name__} (fail-closed)") try: diff --git a/tests/test_anchors_rfc3161_type_floor.py b/tests/test_anchors_rfc3161_type_floor.py index e5a84bcd..9b4eea9b 100644 --- a/tests/test_anchors_rfc3161_type_floor.py +++ b/tests/test_anchors_rfc3161_type_floor.py @@ -98,6 +98,27 @@ def test_a_valid_mapping_is_not_rejected_by_the_floor(self): self.assertIsInstance(res, dict) self.assertIs(res.get("ok"), False) + def test_a_mapping_that_is_not_a_dict_is_accepted(self): + """The floor tests the INTERFACE, not the implementation — and that distinction was earned. + + The first version of this floor checked `isinstance(x, dict)`. The counter-read rejected it for + programming to an implementation, and measuring settled it: every use of these two arguments in + `verify_rfc3161` is `.get(...)` — six call sites, no subscript, no dict-only method, no mutation. + `.get` belongs to the `Mapping` protocol, so a `MappingProxyType` or an `OrderedDict` works fine + and was being refused for no reason. A floor that rejects valid input is a defect of its own, + just a quieter one than the crash it replaced. + """ + from collections import OrderedDict + from types import MappingProxyType + for name, mapping in (("MappingProxyType", MappingProxyType({"rootCertsDerB64": []})), + ("OrderedDict", OrderedDict(rootCertsDerB64=[]))): + with self.subTest(mapping=name): + res = verify_rfc3161(b"", b"", frozen=mapping, rp_trust=None) + self.assertIsInstance(res, dict) + self.assertIs(res.get("ok"), False) + self.assertEqual(res.get("status"), "needs_rp_trust", + f"a {name} was rejected by the floor instead of reaching the verdict") + if __name__ == "__main__": # pragma: no cover unittest.main()