fix(verify_consistency): restore RFC 9162 Step-7 terminal sn==0 check

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 <noreply@anthropic.com>
This commit is contained in:
mrwulf 2026-07-23 18:34:23 +02:00
parent fd2f6baa36
commit ddbb5a4fd8
3 changed files with 166 additions and 1 deletions

View file

@ -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.

View file

@ -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]:

View file

@ -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))