fix(sign): the revocation check failed open on a non-bool answer - #12
Merged
Merged
Conversation
`RevocationStore` is `Container[str] | Callable[[str], bool]`, and the callable's return value was read by truthiness. `None`, `""`, `0` and `[]` all read as "not revoked" and let the key through; the string `"no"` read as revoked. Truthiness is not a reading of revocation status in either direction. `None` is the case that matters and it is not hypothetical. It is what a CRL, status or SCITT lookup returns when its author handled the 200 and forgot every other response, which is exactly the outage the existing `except` clause was written to survive. That clause already treats a store that raises as a rejection, on the stated grounds that an unavailable source is not evidence a key is unrevoked. A store answering `None` has supplied no more evidence than one that raises, and was being believed. The one check in this package that exists to catch a compromised key was the one deciding by truthiness. A callable returning anything other than `True` or `False` is now treated as unable to answer and fails closed through the same path and the same message. The membership branch is untouched: `in` yields a real bool whatever `__contains__` returns, and a test pins that so it does not acquire a guard by accident. The public docstring promised rejection "if the store cannot answer" and was broader than the code. It now names which answers those are. Found by testing the docstring's claims against the behaviour rather than reading them. Nine revocation tests existed and none returned a non-bool, so the gap sat between a covered raise and a covered `False`. 12 tests added, 10 of them red without the guard; the other two are controls that must stay green either way. 1033 to 1045 passed, 1 skipped. Ruff, mypy and check_dashes.py clean. Signed-off-by: Louielunz <48041247+lywinged@users.noreply.github.com>
|
❔ Contributor Check: UNKNOWN
Automated check by AgenTrust Contributor Check. |
This was referenced Aug 27, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Found by a lens that had not been pointed at this repo: testing what the docstrings claim against what the code does, rather than reading them.
verify_record's docstring says, under a heading that reads Revocation (fail closed):Half of that is implemented.
What fails open
RevocationStoreisContainer[str] | Callable[[str], bool]. The callable's answer was read by truthiness:None""0[]"no"TrueFalseTruthiness is not a reading of revocation status in either direction. It let a key through on four values and rejected one on the string
"no".Noneis the case that matters and it is not hypothetical. It is what a CRL, status or SCITT lookup returns when its author handled the 200 and forgot every other response:That is exactly the outage the existing
exceptclause was written to survive. It already treats a store that raises as a rejection, on its own stated grounds that an unavailable source is not evidence a key is unrevoked. A store that answersNonehas supplied no more evidence than one that raises, and was being believed.The one check in this package that exists to catch a compromised key was the one deciding by truthiness.
It is two entry points, not one
provenance.verify_recordimports_check_not_revokedfromsign, and its own docstring makes the claim in the same words:So it failed open identically, and one fix closes both. Verified on this branch, where the refusal now surfaces through that module's own error type:
The fix
A callable returning anything other than
TrueorFalseis treated as unable to answer, and fails closed through the same path and the same message as a store that raises. The two are one fact.The membership branch is untouched and needs no guard:
inyields a real bool whatever__contains__returns. A test pins that, so it does not acquire one by accident.The docstring was broader than the code. It now names which answers count as no answer.
Why nothing caught it
Nine revocation tests already existed, covering set stores, a callable returning
True, a callable returningFalse, a callable that raises, public-key objects, underivable keys, and the trusted-key-not-record rule. None of them returned a non-bool. The gap sat between a covered raise and a coveredFalse, which is a shape a test count cannot show.Measurements
4b21262ruff check src/ tests/mypy src/tools/check_dashes.py12 tests added. 10 of them fail with the guard reverted; the other two are controls that must stay green either way, one asserting
TrueandFalsestill work and one asserting the membership branch is unchanged.Also checked, and holding
The other four claims in the same two docstrings reproduce exactly:
max_age_secondsdefaults to 86400 on a Trust Record and toNoneon a provenance record;max_future_skew_secondsis 300 and is enforced on a provenance record even with no age bound set, which is the#155defect not recurring; the v0.1 profile identifier is refused before any record is examined; and the profile is read before the signature is checked.What this does not cover
The same probe found three exported functions leaking
AttributeErroron their key arguments. That is the type-contract class from #6 and #9, not this one, and it is #13 along with the sweep that should have caught it.