From ddbb5a4fd8ad4cfee0027f713a276c566e6eb695 Mon Sep 17 00:00:00 2001 From: mrwulf Date: Thu, 23 Jul 2026 18:34:23 +0200 Subject: [PATCH] fix(verify_consistency): restore RFC 9162 Step-7 terminal sn==0 check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deployed consistency verifier implemented the RFC 9162 2.1.4.2 bit-navigation loop but its final return checked only the two reconstructed roots, omitting the terminal condition that the new-size navigation counter reach zero. That condition couples the consumed proof length to the claimed tree sizes; without it, a valid proof for one transition verifies under a lied (power-of-two) old size. Flagship: a valid 2->3 proof is accepted under the false claim 1->3 with the size-2 root. Fix: add `and sn == 0` to the final return. This is the corpus's Known Gap 14 (3,867 deployed-accepts-only cases in a pinned 73,573-case family, recorded in public log entry 13). It was found by the project's own differential harness; a post-appeal review round added a faithful RFC oracle as a third comparison, which showed the deployed verifier — not the mechanized model — was the one deviating from RFC 9162, and traced it to the missing terminal check. Scope: verify_consistency's only production caller is the consumer-side pin store, reached only behind a verified head signature. Generation is RFC-correct and unaffected; the live provider service does not run this verifier; the published standalone verify.py has no consistency verifier. An empirical search found 0 realizable pin-advance poisons against an honestly pinned consumer, consistent with Known Gap 14's non-claim. Verification: - New fail-first three-way regression test test_consistency_lied_size_three_way_agreement (deployed / recursive ConsRec model / independent faithful RFC 9162 transliteration) over the honest AND lied-size families; fails pre-fix, passes post-fix. - Historical differential tests (164,479 inclusion; 164,224 consistency) unchanged — the fix rejects nothing honest. - Full suite: 145 passed, 0 failed. Public log entry 13, the attested accumulator commit, and the IACR submission PDF are all unchanged. Vulnerable state tagged vulnerable/sn0-consistency-fd2f6ba. See docs/security-2026-07-23-consistency-terminal-check.md. Co-Authored-By: Claude Fable 5 --- ...y-2026-07-23-consistency-terminal-check.md | 81 +++++++++++++++++++ src/pacta/transparency.py | 7 +- tests/test_paper_verifiers.py | 79 ++++++++++++++++++ 3 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 docs/security-2026-07-23-consistency-terminal-check.md diff --git a/docs/security-2026-07-23-consistency-terminal-check.md b/docs/security-2026-07-23-consistency-terminal-check.md new file mode 100644 index 0000000..0ec8d64 --- /dev/null +++ b/docs/security-2026-07-23-consistency-terminal-check.md @@ -0,0 +1,81 @@ +# Security note: RFC 9162 Step-7 terminal check restored in `verify_consistency` + +**Date:** 2026-07-23 +**Component:** `pacta.transparency.verify_consistency` (consumer-side consistency-proof verifier) +**Severity:** low (RFC-conformance defect; no exploit found through the honest pin-store flow) +**Vulnerable commit:** tagged `vulnerable/sn0-consistency-fd2f6ba` +**Status:** fixed; found by this project's own differential fidelity harness. + +## What was wrong + +RFC 9162 §2.1.4.2 Step 7 requires a consistency proof to satisfy three +conditions: both reconstructed roots must match the supplied roots, **and** the +new-size navigation counter `sn` must reach `0`. That last condition couples the +consumed proof length to the claimed tree sizes. The deployed +`verify_consistency` implemented the RFC bit-navigation loop (including the +mid-loop `sn == 0` guard) but its final return checked only the two +reconstructed roots — it omitted the terminal `sn == 0` conjunct. + +Consequence: when the claimed old size is a power of two, the verifier seeds the +walk with the old root and uses the sizes only as bit-navigation state, so +several distinct (false) old-size claims navigate one proof identically. A valid +proof for one transition therefore verifies under a lied size. Flagship: a valid +`2 → 3` consistency proof is accepted under the false claim `1 → 3` when paired +with the true size-2 root. + +## Scope and reachability (measured, not assumed) + +- The defect is confined to `verify_consistency`. Its **only** production caller + is the consumer-side pin store (`sthstore.check_sth_against_store`), which is + reached only behind a verified head signature in all three of its callers + (`attestation.py`, `cli.py`, `logclient.py`). +- Consistency-proof **generation** (`consistency_proof`) is RFC-correct and was + never affected; every honest proof the system emits verifies everywhere. +- The **live** LTL provider/mirror service does not run `verify_consistency` in + its serving path (it generates proofs, it does not verify them). The published + standalone `verify.py` has no consistency verifier at all (inclusion + + receipts only). So this is a consumer-library defect, not a live-service one. +- An empirical search for a pin-advance poison against an **honestly** pinned + consumer found **0** realizable cases: the two-root check still binds the new + root, and the pin binds `(size, root)` from the consumer's own store, so the + lied-size acceptance does not translate into a pin poison for an honest pin. + No exploit is claimed; the fix restores conformance regardless. + +## The fix + +One conjunct in the final return of `verify_consistency`: + +```python +return old_hash == old_root_hash and new_hash == new_root_hash and sn == 0 +``` + +## Verification + +- **Fail-first regression** `test_consistency_lied_size_three_way_agreement` + (in `tests/test_paper_verifiers.py`): a **three-way** harness comparing the + deployed verifier, the recursive `ConsRec` model, and an independent faithful + RFC 9162 §2.1.4.2 transliteration, over both the honest family and the + lied-size family. It fails against the pre-fix verifier (the flagship lie is + accepted) and passes once `sn == 0` is restored. +- The historical differential tests (164,479 inclusion; 164,224 consistency) + stay green — the fix rejects nothing honest. +- Full suite: 145 passed, 0 failed. +- The independent RFC oracle is honest-complete on the tested range but is not + yet cross-checked against a second independent implementation (ATL / Sigsum); + that cross-check is a follow-up harness item. + +## Honesty / provenance + +This defect is the corpus's Known Gap 14: the project's own fidelity harness +**found** the divergence (3,867 deployed-accepts-only cases in a pinned +73,573-case family) and recorded it in public log entry 13. What the two-way +harness could not do was assign blame — with only the deployed verifier and the +Lean model disagreeing, and both labelled "RFC 9162", the divergence was filed +as a scoped gap rather than a conformance bug. A post-appeal review round added +the missing third oracle (a faithful RFC verifier), which showed the deployed +side was the deviant one, and traced it to the omitted Step 7. + +Public log entry 13 and the attested accumulator commit are unchanged: the +historical experiment remains truthfully recorded and reproducible at the +tagged pre-fix commit. The IACR submission PDF is unchanged. The corpus +Known-Gaps ledger records this closure. diff --git a/src/pacta/transparency.py b/src/pacta/transparency.py index 65ede14..9fb2cf8 100644 --- a/src/pacta/transparency.py +++ b/src/pacta/transparency.py @@ -121,7 +121,12 @@ def verify_consistency( fn >>= 1 sn >>= 1 - return old_hash == old_root_hash and new_hash == new_root_hash + # RFC 9162 2.1.4.2 Step 7 requires the new-size navigation counter to reach + # zero: the consumed proof length must match the claimed tree sizes. Without + # it, distinct (false) old-size claims can navigate one proof to the same + # reconstructed roots, so a valid proof for one transition verifies under a + # lied size. Reconstructing both roots is necessary but not sufficient. + return old_hash == old_root_hash and new_hash == new_root_hash and sn == 0 def attestation_leaf(attestation: dict[str, Any]) -> dict[str, Any]: diff --git a/tests/test_paper_verifiers.py b/tests/test_paper_verifiers.py index df3fe35..e48929f 100644 --- a/tests/test_paper_verifiers.py +++ b/tests/test_paper_verifiers.py @@ -141,3 +141,82 @@ def test_recursive_consistency_equals_deployed_exhaustive(): assert verify_consistency(m, n, r0, r1, P) assert _paper_cons(m, n, r0, r1, P) assert total == 164_224, total # the count cited in the paper + + +# --- independent faithful RFC 9162 2.1.4.2 verifier, incl. Step-7 sn==0 ----- +# A THIRD oracle, structurally distinct from the recursive _paper_cons model, +# so the harness below is three-way (deployed / recursive model / RFC loop). +def _rfc_cons(first, second, fh, sh, path): + if first == 0: + return True + if first > second: + return False + if first == second: + return fh == sh and not path + if not path: + return False + p = ([fh] + list(path)) if (first & (first - 1)) == 0 else list(path) + fn, sn = first - 1, second - 1 + while fn & 1: + fn >>= 1 + sn >>= 1 + fr = sr = p[0] + for c in p[1:]: + if sn == 0: + return False + if (fn & 1) or (fn == sn): + fr = _hnode(c, fr) + sr = _hnode(c, sr) + if not (fn & 1): + while (fn & 1) == 0 and fn != 0: + fn >>= 1 + sn >>= 1 + else: + sr = _hnode(sr, c) + fn >>= 1 + sn >>= 1 + return fr == fh and sr == sh and sn == 0 + + +def test_consistency_lied_size_three_way_agreement(): + """Regression for the RFC 9162 Step-7 terminal check (sn==0). + + The deployed iterative verify_consistency, the recursive ConsRec model + (_paper_cons), and an independent faithful RFC 9162 2.1.4.2 transliteration + (_rfc_cons) must agree on BOTH the honest family AND the lied-size family. + The lied-size dimension is the one the historical differential test above + never varied; it is exactly where the pre-fix verifier (which omitted RFC + Step 7's terminal sn==0) accepted semantically-false size claims. Flagship: + a valid 2->3 proof presented as 1->3 with the size-2 root. This test FAILS + against the pre-fix verifier and passes once sn==0 is restored. + """ + # Flagship named example: rejected by all three verifiers. + L = [f"leaf-{i}".encode() for i in range(3)] + P23 = consistency_proof(L, 2) + R2, R3 = merkle_root(L[:2]), merkle_root(L) + assert verify_consistency(1, 3, R2, R3, P23) is False + assert _paper_cons(1, 3, R2, R3, P23) is False + assert _rfc_cons(1, 3, R2, R3, P23) is False + + N = 48 + honest_total = lied_total = 0 + for n in range(1, N + 1): + data = [f"leaf-{i}".encode() for i in range(n)] + rn = merkle_root(data) + for m in range(1, n + 1): + P = consistency_proof(data, m) + rm = merkle_root(data[:m]) + assert (verify_consistency(m, n, rm, rn, P) + == _paper_cons(m, n, rm, rn, P) + == _rfc_cons(m, n, rm, rn, P) is True), ("honest", n, m) + honest_total += 1 + for mlie in range(1, n): + if mlie == m: + continue + dep = verify_consistency(mlie, n, rm, rn, P) + mod = _paper_cons(mlie, n, rm, rn, P) + rfc = _rfc_cons(mlie, n, rm, rn, P) + assert dep == mod == rfc, ("lied", n, m, mlie, dep, mod, rfc) + lied_total += 1 + assert honest_total == N * (N + 1) // 2 + assert lied_total == sum((n - 1) ** 2 for n in range(1, N + 1))