From 1a89c59268dd2143b25642feacf199253cd2d229 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 21 Aug 2026 22:07:23 +0700 Subject: [PATCH 1/3] fix(proof): read a synthesized path-component layer's direction off the proof MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PathQuery::query_items_at_path` synthesizes a single-`Key` level for every path component above the query's own path (and for positions inside a `subquery_path`), with `left_to_right` hardcoded to `true` in `SinglePathSubquery::from_key_when_in_path`. The prover, by contrast, emits each layer's op family from the *generating* query's direction at that path. Those disagree the moment the generating query is descending at a shared layer — which is exactly what a direction-aligned merge (grove V4, `path_query_methods.merge: 1`) produces for platform's document cursor proofs. `verify_subset_query` then runs the ascending bound-witness machinery over an inverted stream, and: * rejects honest proofs with "Cannot verify lower bound of queried range" when the stream opens on an abridged sibling, and * worse, when the stream opens on a key-bearing node, the ascending `last_push == None` arm reads it as "leftmost node in the tree", the single `Key` item is consumed as satisfied, the end-of-stream absence check never runs, and the verifier returns `Ok` with an EMPTY result set for a subtree that provably exists. No fixed direction is correct for a synthesized level: the generating query is not recoverable from a subset query. But a synthesized level's item list is exactly one `QueryItem::Key`, so its direction carries no query semantics at all — it is purely an encoding property of the proof. So read it from the proof: `proof_stream_direction` reports the op family, refusing a stream that mixes families. That is not a trusted read of an attacker-chosen parameter — `execute` independently checks per op that upright pushes ascend and inverted pushes descend, so a homogeneous stream cannot claim an orientation it does not have. Verifier-only. No prover change, so no proof bytes change; ascending proofs derive `true` and behave bit-identically to before. Co-Authored-By: Claude Opus 5 --- grovedb/src/operations/proof/generate.rs | 7 + grovedb/src/operations/proof/verify.rs | 52 +++- grovedb/src/query/mod.rs | 54 ++++- .../merged_descending_subset_bound_tests.rs | 226 ++++++++++++++++++ grovedb/src/tests/mod.rs | 1 + merk/src/proofs/query/mod.rs | 5 +- merk/src/proofs/query/verify.rs | 121 ++++++++++ 7 files changed, 459 insertions(+), 7 deletions(-) create mode 100644 grovedb/src/tests/merged_descending_subset_bound_tests.rs diff --git a/grovedb/src/operations/proof/generate.rs b/grovedb/src/operations/proof/generate.rs index df859d2a4..e4b9af642 100644 --- a/grovedb/src/operations/proof/generate.rs +++ b/grovedb/src/operations/proof/generate.rs @@ -1905,6 +1905,13 @@ impl GroveDb { .query .has_aggregate_count_and_sum_on_range_anywhere(); + // `query.left_to_right` is used verbatim, synthesized levels + // included: this is the definition of the layer's op family, and + // changing it would change proof bytes. The verifier is the side + // that cannot reproduce this value — a subset query does not know + // what the generating query was — so for a synthesized one-key + // level it reads the orientation back off the op family instead. + // See `SinglePathSubquery::synthesized_path_component`. let mut merk_proof = cost_return_on_error!( &mut cost, self.generate_merk_proof( diff --git a/grovedb/src/operations/proof/verify.rs b/grovedb/src/operations/proof/verify.rs index af7f767ae..d0b99760d 100644 --- a/grovedb/src/operations/proof/verify.rs +++ b/grovedb/src/operations/proof/verify.rs @@ -1400,9 +1400,57 @@ impl GroveDb { query )))?; + // Which direction this layer's op stream is encoded in. + // + // For a real query node the direction is query semantics — it + // decides which end of a range fills a limit — so it comes from + // the query and nothing else. A *synthesized* path-component + // level is different: `query_items_at_path` manufactures it for + // every path component above the query's own path (and for + // positions inside a `subquery_path`), its item list is exactly + // one `QueryItem::Key`, and its `left_to_right` is a fixed + // placeholder. The generating query — which is what chose the + // op family the prover emitted at this path — is not + // recoverable from a subset query, so no fixed value is right: + // a merged descending query emits this layer inverted, and + // running the ascending bound-witness machinery over an + // inverted stream is wrong in both directions (it rejects + // honest proofs, and worse, it can read an absence out of a + // stream that proves presence). + // + // So for a one-key synthesized level, read the orientation off + // the proof's own op family. That is not trusting an + // attacker-chosen parameter: `execute` checks, per op, that + // upright pushes ascend and inverted pushes descend, so a + // stream cannot claim an orientation it does not have — and + // with a single `Key` item the direction cannot reorder, + // truncate or extend the answer either way. Everything that + // binds the result stays where it was: the reconstructed root + // hash still has to match what the parent layer committed, and + // `QueryItem::contains` still gates every returned key. + let single_key_synthesized_level = internal_query.synthesized_path_component + && matches!( + internal_query.items.as_slice(), + [grovedb_merk::proofs::query::QueryItem::Key(_)] + ); + let left_to_right = if single_key_synthesized_level { + grovedb_merk::proofs::query::proof_stream_direction(merk_proof_bytes) + .map_err(|e| { + Error::InvalidProof( + query.clone(), + format!("Invalid V1 proof op stream at path component layer: {}", e), + ) + })? + // An op-less stream carries no orientation; `execute` + // rejects it a moment later for having no root. + .unwrap_or(internal_query.left_to_right) + } else { + internal_query.left_to_right + }; + let level_query = Query { items: internal_query.items.to_vec(), - left_to_right: internal_query.left_to_right, + left_to_right, ..Default::default() }; @@ -1410,7 +1458,7 @@ impl GroveDb { .execute_proof( merk_proof_bytes, *limit_left, - internal_query.left_to_right, + left_to_right, PROOF_VERSION_LATEST, // V1 proof: strict mode rejects items in value hash nodes ) .unwrap() diff --git a/grovedb/src/query/mod.rs b/grovedb/src/query/mod.rs index d4fd9934d..dc0cc644e 100644 --- a/grovedb/src/query/mod.rs +++ b/grovedb/src/query/mod.rs @@ -1577,6 +1577,28 @@ pub struct SinglePathSubquery<'a> { pub left_to_right: bool, /// In the path of the path_query, or in a subquery path pub in_path: Option>, + /// True when this level was *synthesized* from a path component + /// instead of resolved to a real query node — the `Ordering::Less` + /// arm of [`PathQuery::query_items_at_path`] plus every + /// mid-`subquery_path` arm, all of which go through + /// [`SinglePathSubquery::from_key_when_in_path`]. + /// + /// A synthesized level's `items` is exactly one `QueryItem::Key`, + /// so its `left_to_right` carries no query semantics at all: the + /// answer is that one key or nothing, and there is no ordering or + /// limit interaction to observe. The field is a placeholder, fixed + /// at `true`, because the direction the *generating* query used at + /// this path — which is what decided the op family the prover + /// emitted — is not recoverable from a subset query. + /// + /// Proof verifiers must therefore not take the stream's + /// orientation from `left_to_right` on a synthesized level; they + /// read it off the proof's own op family via + /// `grovedb_merk::proofs::query::proof_stream_direction`, which + /// `execute` independently pins to the stream's key ordering. Proof + /// *generation* keeps using `left_to_right` verbatim, so proof + /// bytes are unaffected. + pub synthesized_path_component: bool, } impl fmt::Display for SinglePathSubquery<'_> { @@ -1593,6 +1615,11 @@ impl fmt::Display for SinglePathSubquery<'_> { Some(path) => writeln!(f, " in_path: Some({})", hex_to_ascii(path)), None => writeln!(f, " in_path: None"), }?; + writeln!( + f, + " synthesized_path_component: {}", + self.synthesized_path_component + )?; write!(f, "}}") } } @@ -1624,8 +1651,14 @@ impl<'a> SinglePathSubquery<'a> { SinglePathSubquery { items: Cow::Owned(vec![QueryItem::Key(key.clone())]), has_subquery: HasSubquery::NoSubquery, + // Placeholder — see `synthesized_path_component`. Nothing + // here knows which direction the generating query walked + // this level in, and for a one-key level nothing needs to: + // the direction is an encoding detail of the proof, which + // is where verifiers read it from. left_to_right: true, in_path, + synthesized_path_component: true, } } @@ -1648,6 +1681,7 @@ impl<'a> SinglePathSubquery<'a> { has_subquery, left_to_right: query.left_to_right, in_path: None, + synthesized_path_component: false, } } } @@ -2400,6 +2434,7 @@ mod tests { has_subquery: HasSubquery::NoSubquery, left_to_right: true, in_path: Some(Cow::Borrowed(&root_path_key_2)), + synthesized_path_component: true, } ); } @@ -2420,6 +2455,7 @@ mod tests { * subquery for one item */ left_to_right: true, in_path: None, + synthesized_path_component: false, } ); } @@ -2442,7 +2478,8 @@ mod tests { items: Cow::Owned(vec![QueryItem::Key(subquery_path_key_1.clone())]), has_subquery: HasSubquery::NoSubquery, left_to_right: true, - in_path: Some(Cow::Borrowed(&subquery_path_key_1)) + in_path: Some(Cow::Borrowed(&subquery_path_key_1)), + synthesized_path_component: true, } ); } @@ -2466,7 +2503,8 @@ mod tests { items: Cow::Owned(vec![QueryItem::Key(subquery_path_key_2.clone())]), has_subquery: HasSubquery::NoSubquery, left_to_right: true, - in_path: Some(Cow::Borrowed(&subquery_path_key_2)) + in_path: Some(Cow::Borrowed(&subquery_path_key_2)), + synthesized_path_component: true, } ); } @@ -2493,6 +2531,7 @@ mod tests { * add items underneath */ left_to_right: true, in_path: None, + synthesized_path_component: false, } ); } @@ -2519,6 +2558,7 @@ mod tests { has_subquery: HasSubquery::NoSubquery, left_to_right: true, in_path: None, + synthesized_path_component: true, } ); } @@ -2567,6 +2607,7 @@ mod tests { has_subquery: HasSubquery::Always, left_to_right: true, in_path: None, + synthesized_path_component: false, } ); } @@ -2585,7 +2626,9 @@ mod tests { items: Cow::Owned(vec![QueryItem::Key(quantum_key.clone())]), has_subquery: HasSubquery::NoSubquery, left_to_right: true, - in_path: None, // There should be no path because we are at the end of the path + // There should be no path: we are at the end of the path + in_path: None, + synthesized_path_component: true, } ); } @@ -2640,6 +2683,7 @@ mod tests { has_subquery: HasSubquery::NoSubquery, left_to_right: true, in_path: Some(Cow::Borrowed(&zero_vec)), + synthesized_path_component: true, } ); } @@ -2750,6 +2794,7 @@ mod tests { )), left_to_right: true, in_path: None, + synthesized_path_component: false, } ); } @@ -2768,6 +2813,7 @@ mod tests { has_subquery: HasSubquery::NoSubquery, left_to_right: true, in_path: Some(Cow::Borrowed(&identity_id)), + synthesized_path_component: true, } ); } @@ -2788,6 +2834,7 @@ mod tests { )), left_to_right: true, in_path: None, + synthesized_path_component: false, } ); } @@ -2806,6 +2853,7 @@ mod tests { has_subquery: HasSubquery::NoSubquery, left_to_right: true, in_path: None, + synthesized_path_component: false, } ); } diff --git a/grovedb/src/tests/merged_descending_subset_bound_tests.rs b/grovedb/src/tests/merged_descending_subset_bound_tests.rs new file mode 100644 index 000000000..c1a68102d --- /dev/null +++ b/grovedb/src/tests/merged_descending_subset_bound_tests.rs @@ -0,0 +1,226 @@ +//! A merged proof must stay subset-verifiable in both directions. +//! +//! Shape from production (dashpay/platform's document cursor proofs): one +//! layer holds a documents subtree (`"0"`), a queried index subtree +//! (`"firstName"`), and unqueried sibling index subtrees. The cursor +//! branch selects a key inside `"0"`, the main branch descends through +//! `"firstName"`, and the two are merged — direction-aligned, as the +//! merge requires — into one proof. Verifying the cursor branch alone +//! (`verify_subset_query`) must succeed regardless of direction: the +//! ascending and descending merged proofs commit the same data, and a +//! subset query names keys only, never order-dependent content. + +#[cfg(test)] +mod tests { + use grovedb_version::version::GroveVersion; + + use crate::{ + tests::{make_test_grovedb, TempGroveDb, TEST_LEAF}, + Element, GroveDb, PathQuery, Query, SizedQuery, + }; + + /// `[TEST_LEAF, person]` with a documents subtree `"0"` (two items), + /// a queried index subtree `"firstName"` (one item), and an + /// unqueried sibling index subtree `"middleName"` that the proof + /// will abridge. + pub(super) fn build_layered_fixture(gv: &GroveVersion) -> TempGroveDb { + let db = make_test_grovedb(gv); + db.insert( + [TEST_LEAF].as_ref(), + b"person", + Element::empty_tree(), + None, + None, + gv, + ) + .unwrap() + .expect("insert person tree"); + for subtree in [b"0".as_ref(), b"firstName", b"middleName"] { + db.insert( + [TEST_LEAF, b"person"].as_ref(), + subtree, + Element::empty_tree(), + None, + None, + gv, + ) + .unwrap() + .expect("insert layer subtree"); + } + for doc in [b"docA".as_ref(), b"docB"] { + db.insert( + [TEST_LEAF, b"person", b"0"].as_ref(), + doc, + Element::new_item(doc.to_vec()), + None, + None, + gv, + ) + .unwrap() + .expect("insert document"); + } + db.insert( + [TEST_LEAF, b"person", b"firstName"].as_ref(), + b"Chris", + Element::new_item(b"Chris".to_vec()), + None, + None, + gv, + ) + .unwrap() + .expect("insert index row"); + db.insert( + [TEST_LEAF, b"person", b"middleName"].as_ref(), + b"x", + Element::new_item(b"x".to_vec()), + None, + None, + gv, + ) + .unwrap() + .expect("insert unqueried index row"); + db + } + + fn cursor_and_main_queries(left_to_right: bool) -> (PathQuery, PathQuery) { + let mut cursor_q = Query::new_with_direction(left_to_right); + cursor_q.insert_key(b"docA".to_vec()); + let cursor_pq = PathQuery::new( + vec![TEST_LEAF.to_vec(), b"person".to_vec(), b"0".to_vec()], + SizedQuery::new(cursor_q, None, None), + ); + + let mut main_q = Query::new_with_direction(left_to_right); + main_q.insert_range_from(b"Chris".to_vec()..); + let main_pq = PathQuery::new( + vec![ + TEST_LEAF.to_vec(), + b"person".to_vec(), + b"firstName".to_vec(), + ], + SizedQuery::new(main_q, None, None), + ); + + (cursor_pq, main_pq) + } + + fn merged_subset_round_trip(left_to_right: bool) { + let gv = GroveVersion::latest(); + let db = build_layered_fixture(gv); + let (cursor_pq, main_pq) = cursor_and_main_queries(left_to_right); + + let merged = PathQuery::merge(vec![&cursor_pq, &main_pq], gv).expect("aligned merge"); + let proof = db + .prove_query(&merged, None, gv) + .unwrap() + .expect("prove merged query"); + + // The whole merged query must verify... + GroveDb::verify_query(&proof, &merged, gv) + .unwrap_or_else(|e| panic!("full merged verify (ltr={left_to_right}): {e}")); + + // ...and so must the cursor branch alone: subset verification is + // how a client extracts one branch (e.g. the pagination cursor + // document) from a combined proof. + let (_, proved) = GroveDb::verify_subset_query(&proof, &cursor_pq, gv) + .unwrap_or_else(|e| panic!("subset cursor verify (ltr={left_to_right}): {e}")); + assert_eq!(proved.len(), 1, "exactly the cursor document"); + assert_eq!(proved[0].1, b"docA".to_vec()); + } + + #[test] + fn merged_ascending_proof_subset_verifies_cursor_branch() { + merged_subset_round_trip(true); + } + + /// FAILS ON DEVELOP — deliberately not ignored: this test IS the bug + /// report. + /// + /// The full merged verify passes, but `verify_subset_query` of the + /// cursor branch rejects with "Cannot verify lower bound of queried + /// range". The prover classified the shared layer against the merged + /// (descending) query and emitted its ops inverted; subset + /// verification re-derives that layer from the cursor path query + /// alone, and `query_items_at_path` synthesizes path-component levels + /// with a hardcoded ascending direction + /// (`SinglePathSubquery::from_key_when_in_path`), so the verifier + /// runs the ascending bound-witness check against inverted pushes and + /// trips on the first hash-abridged sibling. + /// + /// Note for the fix: no fixed direction is correct for synthesized + /// levels — the generating query is unknowable from the subset query. + /// Hardcoding ascending is this bug; inheriting the subset query's + /// direction instead breaks proofs whose generation synthesized the + /// same level ascending (verified against dashpay/platform's + /// protocol-v13 frozen ascending cursor-proof test). + #[test] + fn merged_descending_proof_subset_verifies_cursor_branch() { + merged_subset_round_trip(false); + } +} + +/// The same direction mismatch, seen from the soundness side. +/// +/// A synthesized path-component level verified with the wrong +/// orientation does not only reject honest proofs. When the first node +/// the descending stream emits happens to be key-bearing, the +/// ascending `last_push == None` arm reads it as "this is the leftmost +/// node in the tree", the single `Key` item is consumed as satisfied, +/// and the end-of-stream absence check never runs — so the verifier +/// returns `Ok` with an empty result set for a subtree that provably +/// exists, with the rest of the layer hash-abridged behind it. +/// +/// A client asking "give me the cursor document" would read that empty +/// result as "the document is not there". +/// +/// This needs no merge: any query whose node at a shared layer is +/// descending makes the prover emit that layer inverted. +#[cfg(test)] +mod false_absence_tests { + use grovedb_version::version::GroveVersion; + + use super::tests::build_layered_fixture; + use crate::{tests::TEST_LEAF, GroveDb, PathQuery, Query, SizedQuery}; + + #[test] + fn descending_layer_must_not_prove_a_present_subtree_absent() { + let gv = GroveVersion::latest(); + let db = build_layered_fixture(gv); + + // Generating query: descend into the LAST key of the shared + // layer, right to left. `"middleName"` sorts above both `"0"` + // and `"firstName"`, so the inverted stream opens on a + // key-bearing node and abridges everything below it. + let mut generating = Query::new_with_direction(false); + generating.insert_key(b"middleName".to_vec()); + generating.set_subquery(Query::new()); + let generating_pq = PathQuery::new( + vec![TEST_LEAF.to_vec(), b"person".to_vec()], + SizedQuery::new(generating, None, None), + ); + + let proof = db + .prove_query(&generating_pq, None, gv) + .unwrap() + .expect("prove descending query"); + GroveDb::verify_query(&proof, &generating_pq, gv).expect("full verify of its own query"); + + // Now subset-verify a *different* branch of the same layer: + // `"0"`, which exists and holds two documents, but which this + // proof hash-abridges. + let mut cursor = Query::new(); + cursor.insert_key(b"docA".to_vec()); + let cursor_pq = PathQuery::new( + vec![TEST_LEAF.to_vec(), b"person".to_vec(), b"0".to_vec()], + SizedQuery::new(cursor, None, None), + ); + + match GroveDb::verify_subset_query(&proof, &cursor_pq, gv) { + Err(_) => {} + Ok((_, proved)) => panic!( + "verifier accepted an abridged layer as proof that subtree \"0\" is \ + absent — it holds docA and docB. Result set: {proved:?}" + ), + } + } +} diff --git a/grovedb/src/tests/mod.rs b/grovedb/src/tests/mod.rs index ec54c73c5..2536a7972 100644 --- a/grovedb/src/tests/mod.rs +++ b/grovedb/src/tests/mod.rs @@ -65,6 +65,7 @@ mod indexed_tree_secondary_drift_tests; mod indexed_tree_security_regression_tests; mod is_empty_tree_tests; mod merge_versioning_tests; +mod merged_descending_subset_bound_tests; mod misc_coverage_tests; mod mmr_tree_tests; mod non_counted_tests; diff --git a/merk/src/proofs/query/mod.rs b/merk/src/proofs/query/mod.rs index 3170c8784..8d607317b 100644 --- a/merk/src/proofs/query/mod.rs +++ b/merk/src/proofs/query/mod.rs @@ -41,8 +41,9 @@ use grovedb_version::version::GroveVersion; pub use map::{Map, MapBuilder}; #[cfg(any(feature = "minimal", feature = "verify"))] pub use verify::{ - boundaries_in_proof, key_exists_as_boundary_in_proof, ProofVerificationResult, - ProvedKeyOptionalValue, ProvedKeyValue, QueryProofVerify, VerifyOptions, PROOF_VERSION_LATEST, + boundaries_in_proof, key_exists_as_boundary_in_proof, proof_stream_direction, + ProofVerificationResult, ProvedKeyOptionalValue, ProvedKeyValue, QueryProofVerify, + VerifyOptions, PROOF_VERSION_LATEST, }; #[cfg(feature = "minimal")] use {super::Op, std::collections::LinkedList}; diff --git a/merk/src/proofs/query/verify.rs b/merk/src/proofs/query/verify.rs index e5e4a92c9..1f7584990 100644 --- a/merk/src/proofs/query/verify.rs +++ b/merk/src/proofs/query/verify.rs @@ -1115,6 +1115,55 @@ mod provable_count_provable_sum_tree_bound_regression_tests { } } +/// The orientation of a Merk layer proof's op stream, read off the op +/// families in the bytes themselves. +/// +/// Every proof op comes in an upright / inverted pair — `Push` / +/// `PushInverted`, `Parent` / `ParentInverted`, `Child` / +/// `ChildInverted` — and `create_proof` picks the family once per layer +/// from a single `left_to_right`, so an honest layer proof is +/// homogeneous: all upright (nodes emitted in ascending key order) or +/// all inverted (descending). +/// +/// This is deliberately **not** a trusted read of a proof-supplied +/// parameter. [`execute`] independently checks, for every op it +/// decodes, that an upright push's key is strictly greater than the +/// previous key-bearing node's and an inverted push's key strictly +/// less. A stream that claims one orientation while being ordered the +/// other way is therefore rejected by `execute` itself, before any +/// orientation-sensitive bound-witness check can be misapplied: the +/// orientation reported here is pinned to a structural property of the +/// same bytes, not chosen freely by whoever produced them. +/// +/// Returns `Ok(Some(true))` for an all-upright stream, `Ok(Some(false))` +/// for an all-inverted one, `Ok(None)` when there is no +/// direction-bearing op at all (an empty stream, which `execute` then +/// rejects on its own), and `Err` for a stream that mixes the two: no +/// honest prover emits one, and a mixed stream has no single +/// orientation for the bound-witness checks to be correct against, so +/// it is refused rather than guessed at. +pub fn proof_stream_direction(proof_bytes: &[u8]) -> Result, Error> { + let mut direction: Option = None; + for op_result in Decoder::new(proof_bytes) { + let upright = match op_result? { + Op::Push(_) | Op::Parent | Op::Child => true, + Op::PushInverted(_) | Op::ParentInverted | Op::ChildInverted => false, + }; + match direction { + None => direction = Some(upright), + Some(previous) if previous == upright => {} + Some(_) => { + return Err(Error::InvalidProofError( + "Proof mixes upright and inverted ops; a layer proof is emitted \ + entirely in one direction" + .to_string(), + )); + } + } + } + Ok(direction) +} + /// Returns all boundary keys found in the given merk proof bytes. /// Boundary keys appear as `KVDigest`, `KVDigestCount`, `KVDigestSum`, /// or `KVDigestCountSum` (dual-axis PCPS) nodes — they prove a key @@ -1143,3 +1192,75 @@ pub fn boundaries_in_proof(proof_bytes: &[u8]) -> Result>, Error> { } Ok(keys) } + +#[cfg(test)] +mod proof_stream_direction_tests { + //! `proof_stream_direction` is what lets a verifier run the + //! orientation-sensitive bound-witness checks over a layer whose + //! generating direction it cannot know (a synthesized + //! path-component level under `verify_subset_query`). It must + //! report the op family faithfully and refuse to pick one when the + //! stream does not have a single family. + + use grovedb_query::proofs::encode_into; + + use super::proof_stream_direction; + use crate::proofs::{Node, Op}; + + fn encoded(ops: &[Op]) -> Vec { + let mut bytes = vec![]; + encode_into(ops.iter(), &mut bytes); + bytes + } + + #[test] + fn upright_stream_reads_as_left_to_right() { + let bytes = encoded(&[ + Op::Push(Node::KV(vec![1], vec![1])), + Op::Push(Node::KV(vec![2], vec![2])), + Op::Parent, + Op::Push(Node::KV(vec![3], vec![3])), + Op::Child, + ]); + assert_eq!(proof_stream_direction(&bytes).expect("reads"), Some(true)); + } + + #[test] + fn inverted_stream_reads_as_right_to_left() { + let bytes = encoded(&[ + Op::PushInverted(Node::KV(vec![3], vec![3])), + Op::PushInverted(Node::KV(vec![2], vec![2])), + Op::ParentInverted, + Op::PushInverted(Node::KV(vec![1], vec![1])), + Op::ChildInverted, + ]); + assert_eq!(proof_stream_direction(&bytes).expect("reads"), Some(false)); + } + + #[test] + fn empty_stream_has_no_direction() { + assert_eq!(proof_stream_direction(&[]).expect("reads"), None); + } + + /// A mixed stream has no single orientation, so there is no correct + /// mirror of the bound-witness check to run against it. Refuse it + /// rather than pick from the first op — an all-but-the-first + /// inverted stream is exactly how a forger would try to get the + /// ascending checks applied to a descending stream. + #[test] + fn mixed_stream_is_refused() { + let bytes = encoded(&[ + Op::Push(Node::KV(vec![9], vec![9])), + Op::PushInverted(Node::KV(vec![8], vec![8])), + ]); + proof_stream_direction(&bytes).expect_err("mixed families must not resolve to a direction"); + + // Mixed in the structural ops alone counts too. + let bytes = encoded(&[ + Op::Push(Node::KV(vec![1], vec![1])), + Op::Push(Node::KV(vec![2], vec![2])), + Op::ParentInverted, + ]); + proof_stream_direction(&bytes).expect_err("mixed structural ops must not resolve"); + } +} From 1d72cd0dcc1661fb0eadc3970c50b6589ff050ec Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 21 Aug 2026 22:33:47 +0700 Subject: [PATCH 2/3] docs(tests): reword #815 repro comment as a regression description The "FAILS ON DEVELOP" framing was accurate for the test-only bug report; with the fix in the same change it read as a standing failure. Co-Authored-By: Claude Opus 5 --- .../merged_descending_subset_bound_tests.rs | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/grovedb/src/tests/merged_descending_subset_bound_tests.rs b/grovedb/src/tests/merged_descending_subset_bound_tests.rs index c1a68102d..ad163adef 100644 --- a/grovedb/src/tests/merged_descending_subset_bound_tests.rs +++ b/grovedb/src/tests/merged_descending_subset_bound_tests.rs @@ -133,23 +133,23 @@ mod tests { merged_subset_round_trip(true); } - /// FAILS ON DEVELOP — deliberately not ignored: this test IS the bug - /// report. + /// Regression for issue #815. /// - /// The full merged verify passes, but `verify_subset_query` of the - /// cursor branch rejects with "Cannot verify lower bound of queried - /// range". The prover classified the shared layer against the merged - /// (descending) query and emitted its ops inverted; subset + /// The prover classifies the shared layer against the merged + /// (descending) query and emits its ops inverted; subset /// verification re-derives that layer from the cursor path query - /// alone, and `query_items_at_path` synthesizes path-component levels - /// with a hardcoded ascending direction - /// (`SinglePathSubquery::from_key_when_in_path`), so the verifier - /// runs the ascending bound-witness check against inverted pushes and - /// trips on the first hash-abridged sibling. + /// alone, where `query_items_at_path` synthesizes path-component + /// levels with a placeholder direction + /// (`SinglePathSubquery::from_key_when_in_path`). The verifier used + /// to run the ascending bound-witness check against the inverted + /// pushes and reject with "Cannot verify lower bound of queried + /// range" on the first hash-abridged sibling; it now reads the + /// level's orientation off the proof's own op family + /// (`proof_stream_direction`). /// - /// Note for the fix: no fixed direction is correct for synthesized - /// levels — the generating query is unknowable from the subset query. - /// Hardcoding ascending is this bug; inheriting the subset query's + /// No fixed direction is correct for synthesized levels — the + /// generating query is not recoverable from the subset query. + /// Hardcoding ascending was this bug; inheriting the subset query's /// direction instead breaks proofs whose generation synthesized the /// same level ascending (verified against dashpay/platform's /// protocol-v13 frozen ascending cursor-proof test). From d77fd642616e2092d2e9837569e1caee5efb1327 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 21 Aug 2026 23:32:11 +0700 Subject: [PATCH 3/3] fix(proof): bound proof_stream_direction by MAX_PROOF_OPS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The direction scan runs on untrusted bytes before execute's bounded pass, so without its own cap an oversized homogeneous stream would be fully decoded — node allocations included — only to be rejected by execute at op 50,001. Enforce the same cap during the scan; any stream over it fails verification regardless, so no verdict changes. Co-Authored-By: Claude Opus 5 --- merk/src/proofs/query/verify.rs | 51 ++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/merk/src/proofs/query/verify.rs b/merk/src/proofs/query/verify.rs index 1f7584990..eafbfca5a 100644 --- a/merk/src/proofs/query/verify.rs +++ b/merk/src/proofs/query/verify.rs @@ -7,7 +7,11 @@ use grovedb_element::ElementType; use crate::proofs::query::{Map, MapBuilder}; use crate::{ error::Error, - proofs::{hex_to_ascii, tree::execute, Decoder, Node, Op, Query}, + proofs::{ + hex_to_ascii, + tree::{execute, MAX_PROOF_OPS}, + Decoder, Node, Op, Query, + }, tree::{combine_hash, value_hash}, CryptoHash as MerkHash, CryptoHash, }; @@ -1142,9 +1146,25 @@ mod provable_count_provable_sum_tree_bound_regression_tests { /// honest prover emits one, and a mixed stream has no single /// orientation for the bound-witness checks to be correct against, so /// it is refused rather than guessed at. +/// +/// The scan enforces the same [`MAX_PROOF_OPS`] bound as [`execute`]: +/// this pass runs on untrusted bytes *before* the bounded execution +/// pass, so without its own cap an oversized stream would be fully +/// decoded — node allocations included — only to be rejected by +/// `execute` at op 50,001. Any stream over the cap fails verification +/// regardless, so rejecting it here changes no verdict, only how much +/// work the verifier spends reaching it. pub fn proof_stream_direction(proof_bytes: &[u8]) -> Result, Error> { let mut direction: Option = None; + let mut op_count: usize = 0; for op_result in Decoder::new(proof_bytes) { + op_count += 1; + if op_count > MAX_PROOF_OPS { + return Err(Error::InvalidProofError(format!( + "Proof exceeds maximum operation count ({})", + MAX_PROOF_OPS + ))); + } let upright = match op_result? { Op::Push(_) | Op::Parent | Op::Child => true, Op::PushInverted(_) | Op::ParentInverted | Op::ChildInverted => false, @@ -1263,4 +1283,33 @@ mod proof_stream_direction_tests { ]); proof_stream_direction(&bytes).expect_err("mixed structural ops must not resolve"); } + + /// The scan runs on untrusted bytes before the bounded execution + /// pass, so it must enforce the same op-count cap as `execute` — + /// otherwise an oversized homogeneous stream would be fully decoded + /// here only to be rejected there. Exactly at the cap still reads + /// (matching `execute`, which errors only when the count exceeds + /// it); one past the cap is refused. + #[test] + fn oversized_stream_is_refused_at_the_execute_cap() { + use crate::proofs::tree::MAX_PROOF_OPS; + + let at_cap: Vec = (0..MAX_PROOF_OPS) + .map(|_| Op::Push(Node::Hash([0u8; 32]))) + .collect(); + assert_eq!( + proof_stream_direction(&encoded(&at_cap)).expect("at-cap stream reads"), + Some(true) + ); + + let over_cap: Vec = (0..=MAX_PROOF_OPS) + .map(|_| Op::Push(Node::Hash([0u8; 32]))) + .collect(); + let err = proof_stream_direction(&encoded(&over_cap)) + .expect_err("over-cap stream must be refused before full decode"); + assert!( + err.to_string().contains("maximum operation count"), + "unexpected error: {err}" + ); + } }