diff --git a/src/proofbundle/anchors_rfc3161.py b/src/proofbundle/anchors_rfc3161.py index 4aa476b..a66f0b7 100644 --- a/src/proofbundle/anchors_rfc3161.py +++ b/src/proofbundle/anchors_rfc3161.py @@ -46,6 +46,40 @@ 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. + # 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, _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: 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 0000000..9b4eea9 --- /dev/null +++ b/tests/test_anchors_rfc3161_type_floor.py @@ -0,0 +1,124 @@ +"""`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) + + 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()