Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions src/proofbundle/anchors_rfc3161.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
124 changes: 124 additions & 0 deletions tests/test_anchors_rfc3161_type_floor.py
Original file line number Diff line number Diff line change
@@ -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()