mirror of
https://github.com/saymrwulf/proof-aware-crypto-tooling-agent.git
synced 2026-09-03 19:53:43 +00:00
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:
parent
fd2f6baa36
commit
ddbb5a4fd8
3 changed files with 166 additions and 1 deletions
81
docs/security-2026-07-23-consistency-terminal-check.md
Normal file
81
docs/security-2026-07-23-consistency-terminal-check.md
Normal 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.
|
||||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Reference in a new issue