From the 1st of June to the 12th of June (10 working days),
two consultants from zkSecurity reviewed the generalized-bulletproofs crate inside the monero-oxide project,
commit f3660043e042b5608fc9c8ce1383cee238da9d0a for MAGIC Grants.
This code is slated for use in the upcoming Monero ‘fullchain membership proofs’ (FCMP++) and a soundness/privacy critical component of the whole system.
We reviewed the codebase as a general library, meaning, we did not discount findings simply because the current application does not trigger the issue.
The code was found to be well-written and easy to understand.
The code was reviewed along with its associated paper, for context and because of some concerns about the formal writeup of the extractor (proof of knowledge soundness), we include here a writeup / motivation for the ‘generalized bulletproofs’ protocol as well as a formal proof of (computational) knowledge soundness.
Notation
Let denote the scalar field and a prime-order group (in practice a subgroup of an elliptic curve), written additively.
Generators are denoted and ; commitments are capitalized () and blinding factors written as Greek letters ().
We write to denote sampling an element from the set uniformly.
An (honest) vector Pedersen commitment to with randomness is , and a scalar commitment to is .
Bold font denotes vectors, , with and the all-ones and all-zeros vectors, respectively; the witness wires (left, right, output) lie in , where and are the number of multiplication gates and linear constraints.
We denote by the inner product, and by the Hadamard (entry-wise) product; is the entry-wise inverse, so .
For we write for the vector of powers and abbreviate ; likewise .
Generalized Bulletproofs
Bulletproofs provide a Non-Interactive (perfectly) Zero-Knowledge Argument-of-Knowledge (NIZKAoK)
for knowledge of such that the following relation holds:
Where and are the witnesses, with
being the opening of a number of (scalar) Pedersen commitments .
Compared to the standard bulletproofs description, to follow the implementation,
we have moved and over, so the relation is a single linear combination equal to .
Generalized bulletproofs extends this with “pre-committed” vectors ,
each the opening of a Pedersen commitment of some higher dimension:
For vector commitments and scalar commitments .
Each committed vector comes with its own public weight matrix and public commitment .
The “” term is an artifact of the proof system: it lets a prover who only knows an opening which also depends on the generators complete a proof,
however these “wires” cannot be “accessed” from within the circuit.
To build context for the following code-level review, let’s walk through the Bulletproof arithmetization, which turns this R1CS-equivalent system into an inner product relation, between uniformly random vectors.
GBP Arith 1: R1CS IP
To simplify the claim:
The verifier samples and ,
and defines and .
We use this to compute linear combinations of every row of each expression:
In case either of the old relations did not hold,
this new relation only holds with probability .
Finally, we can compute a linear combination of each of these equations to end up with a single equation which must equal zero,
here, we “reuse” the challenge to “shift” the second equation:
In summary, we went from an equation over vectors to single field elements using the challenges and .
Moving in and splitting the inner products, we can rewrite the second part of the expression as:
Let us define:
Note that the verifier can compute all the vectors itself, since the matrices are public.
We are now left to “deal” with:
Splitting the first inner product, we get the expression that we want to look at going forward:
The next issue at hand is that the expression above has many inner products.
Since we need to do an expensive folding argument for every inner product we would like to avoid this:
we want to combine all these inner products into a single one.
The trick is going to be to interpret these as committed vector polynomials,
enabling us to evaluate many parallel polynomials of low degree.
More on that now…
GBP Arith 2: Vector Polynomials
An “” dimensional “vector polynomial” consists of polynomials “in parallel”:
In other words, a polynomial with coefficients in and evaluation points from ,
such that plugging an into evaluates to .
This notion is useful, because (vector) Pedersen commitments allow us to commit to a vector polynomial efficiently (independently of the dimension ):
As well as homomorphically evaluate every coordinate of the vector polynomial at some public point :
Pedersen vector commitments support one further homomorphic operation we will rely on: multiplying the committed vector by a public vector, i.e. a Hadamard product with a public factor, simply by a change of basis. Writing the commitment to in basis explicitly as , a public Hadamard factor is applied to the opening by re-committing in the basis :
In other words, a commitment to in basis is also a commitment to in the basis with the same blinding : to apply a public Hadamard factor to the opening, one re-commits in the basis . This holds because leaves each generator’s contribution unchanged and the blinding term is untouched.
The “inner product” between two vector polynomials is defined in the intuitive way (for any module over any ring):
taking the coordinate-wise product of polynomials and summing:
Note that and .
GBP Arith 3: IP Single IP
Viewing the vectors as vector polynomials is useful because it allows us to batch check independent inner products.
Suppose we have two inner products:
We define the vector polynomials (“left/right”):
And consider the inner product, then the desired inner product lands in the square term:
Where are some cross-term garbage we don’t care about. More generally: we define a “left polynomial” where powers increase for every left term in the series of inner products and a “right polynomial” where the powers decrease for every right term, then the terms will “align” at the “middle power”. i.e. in general, suppose we have:
Then we define:
In which case, the ‘th coefficient of is .
This observation suggests the following approach to reduce a sum of multiple inner products,
given commitments to every vector, to a single inner product as follows:
Prover sends commitments to the coefficients , where we are interested in , which is usually implicit (e.g. fixed to ). Then both parties locally define:
Verifier samples
Both sides compute commitments to the vectors:
And a commitment to the field element , using the homomorphic property of the Pedersen commitments. We now have just a single inner product claim about Pedersen commitments:
GBP Arith 4: Combining
Recall the expression we were trying to verify,
before our digression about batching inner products and vector polynomials:
To apply the techniques above, we want to:
Eliminate Pairwise Products.
We haven’t yet described a technique for proving Hadamard (pairwise) products of secret (committed) vectors,
so we get to get rid of ; we want each secret to appear on a different side of an inner product.
Collect Common Terms.
Reduce the number of inner products, for efficiency, by collecting common terms.
This reduces the degree of the vector polynomials, meaning: less committed coefficients and less cross-terms.
Eliminate Secret-Secret Hadamard Product
Note that the left side of the inner product is public, so let us move one secret to each side, using to get rid of the Hadamard product between secret values:
Becomes
Note that we know how to deal with a Hadamard product between a secret and a public value.
Combine the terms
Becomes
Rewrite Inner Product
Using rewrite:
Becomes
Collect Terms
This was our motivation for the previous step:
Becomes
Add to Both Sides
Add the public scalar to both sides (note that on the left becomes ):
Becomes
Note that does not depend on the witness! (the verifier can compute it)
Combine Terms
Becomes
Combine Terms
Becomes
So we are left with inner products: two coming from the terms and one for each of the committed vectors . All these are combined into a single inner product using the previous technique based on vector polynomials.
Note that during verification everything except the inner products is known to the verifier: (on the left) and are public, and is a commitment to a single field element, formed homomorphically from the . Those inner products are folded into a single one next.
Arithmetization
Public inputs: generators and ; weight matrices , , , , with constant ; vector commitments and scalar commitments . Write for the target monomial.
Prover Verifier.
Sample and .
Send .
Verifier Prover.
Sample .
Send .
Both parties compute:
The challenge vectors
The weights
Prover Verifier.
Recall that the inner products to batch are:
Build (powers as in GBP Arith 3) with nonzero coefficients:
:
at
at for
at
at
:
at
at for
at
at for
at
Corresponding to generators in and respectively.
; the target coefficient is .
Sample for every and set .
Send .
Verifier Prover.
Sample .
Send .
Prover Verifier.
Opening of (value , blinding ):
Opening of (vectors , blinding ):
With the blinding factors of the and the blinding factor of .
Send .
Verifier.
Set .
Define:
Compute:
Check:
Check:
Computational Special Soundness
We prove that the arithmetization (‘Generalized Bulletproofs’) is an argument of knowledge for . The theorem below establishes -computational special soundness. By Attema, Fehr and Klooß (TCC 2022) the Fiat-Shamir compiled argument then has tight security: under the hardness of discrete log over it is knowledge sound with knowledge error at most , where is the number of random-oracle queries made by the adversary.
Theorem.Assume has full column rank , i.e. a left inverse. The full arithmetization is -computationally special sound for : from any -tree of accepting transcripts a deterministic extractor returns either a witness for , or a non-trivial discrete-log relation among the generators , i.e. a non-zero with .
The Tree of Transcripts. A -tree fixes the first message at the root and branches on the three challenges, recording each verifier message at its node: distinct children on ; under each, distinct children on , each with its ; under each, distinct leaves on , each with its opening . Every root-to-leaf path is an accepting transcript. Index a -node by and its -leaves by , so indexes a full transcript. The three arities are forced by the three interpolations below: for ; for the constraint rows together with the constant Hadamard term; for the Hadamard coordinates.
The tree of accepting transcripts: each edge is a verifier challenge (, then , then ), and every root-to-leaf path is an accepting transcript (one highlighted).
Step 1: -Vandermonde Read-off. Fix a node . The verifier’s is the value at the leaf’s challenge of a polynomial whose coefficients are:
With for .
The leaves of the node share and use distinct , so the -opening check at leaf :
Is a Vandermonde system in ; inverting it writes each as a representation in the basis. When the node’s collision candidates (Step 5) all vanish, the -block of at every degree equals the honest coefficient and the -block at every degree equals the honest coefficient: the public degrees and open purely on to and ; degree gives on and on ; degrees , , give the wires on and on . The lone degree- -block — the -part of — is unconstrained; the relation tolerates this slack, and since its convolution partner is the degree- coefficient of , which is , it never contributes downstream.
Step 2: The Per-Node Identity . With the leaf openings pinned to the honest , the -coefficient of — the committed target , recovered from the leaves by inverting the same -Vandermonde at degree — expands without invoking any constraint into , the -aggregated linear rows, and the -weighted Hadamard term . The first verifier check independently pins the same coefficient to ; here the check couples to the aggregate through its degree- coefficient, the two agreeing once the corresponding -collision candidate of Step 5 vanishes. Equating the two expansions and cancelling gives, at every node :
Step 3: Deaggregation over then . It suffices to know on one reference row and one reference column. Fix a reference -child: as ranges over the children of that node, is a degree- polynomial in — constant term , row at — vanishing at distinct points, so a Vandermonde in forces every row to zero. With the rows gone, on the reference column ( fixed to a reference child, ranging over its children) collapses to at distinct ; a Vandermonde in forces each coordinate. This yields the first two relation clauses:
(The arity is the constant term plus rows; the L-shape avoids needing on the full grid.)
Step 4: Commitment Openings. Two clauses remain, read straight off Step 1. The degree- coefficient is , whose -, - and -blocks give the vector-commitment clause:
with the slack retained — precisely the term the improved protocol removes. For the scalar commitments, the first check’s degree- coefficient opens the aggregate as a -combination at each of the -children; inverting the Vandermonde in and applying the left inverse of recovers each .
Step 5: Witness or Relation. The extractor reads a candidate witness off the Step-1 representations at a fixed reference (the first - and -children): from the -block at degree minus the public ; at degree ; and from the - and -blocks at degree ; from the -block at degree , minus the public and rescaled by ; from the -block at degree , rescaled by ; and from the degree- first-check coefficients via the left inverse of . It is computed by Lagrange-form Vandermonde inversion and Gaussian elimination — no discrete-log search. It also forms a list of candidate discrete-log relations: differences of two recovered representations of the same group element — the public coefficients ; the challenge-independent commitments across distinct nodes; the degree- -candidate of Step 2; and the high-degree coefficients, which open directly. Each candidate satisfies by construction, being a difference of two openings of the same commitment; it is non-trivial exactly when .
The extractor outputs if , and otherwise the first non-zero candidate relation; we argue the contrapositive. If every candidate vanishes, the Step-1 read-offs hold and the degree- identity of Step 2 holds at every node, so holds on the reference row and column; Step 3 deaggregates it into the first two clauses, and Step 4 supplies the vector- and scalar-commitment clauses. All four clauses hold, so , contradicting the assumption. Hence a failing yields a non-zero candidate, which the extractor returns as the discrete-log relation.
Reduce
The check above ties to the committed polynomial ; the remaining verifier check is membership in a single inner-product relation , with statement and witness :
Here and . The generators are public; the verifier derives the remaining statement components from the protocol transcript, and the prover holds the witness . We abbreviate .
To recursively fold this relation, we first want to reduce it to:
The verifier samples a challenge ; both parties set (a generator independent of ) and update the commitment:
Subtracting removes the blinding term from the commitment, and adding encodes the claimed inner product in the component. Since , the updated statement is an instance of .
Inner-Product Argument
The argument proves membership in on length- vectors, initially . In each round, the prover and verifier combine the left and right halves of each vector into vectors of half the length. After rounds, the prover has sent group elements and sends two final scalars.
Folding
Split each vector in half:
The prover sends the two cross terms:
The verifier replies with a challenge , and both sides fold the generators and statement to half length:
While the prover folds the witness the same way:
A simple calculation shows the relation is preserved, . The folded statement and witness then form a smaller instance of the same relation, .
Base Case
After rounds the vectors have length one, leaving the base relation . The prover sends the two scalars , and the verifier accepts iff:
The verifier never folds the generators one round at a time: the round challenges assign each original generator a fixed coefficient (a product of the ), so the whole check collapses into a single multi-exponentiation, batched with the rest of verification.
Improvements
We describe two improvements to the protocol above: a cheaper arithmetization, and an inversion-free inner-product argument with a tighter and simpler proof of computational special soundness.
Arithmetization
Recall that the full arithmetization spreads across degree , so the prover commits to all of its coefficients. Most of that degree is wasted: it exists only to park the two halves of (and of ) on a shared monomial, so that they can be jointly committed;
this is a bad ‘optimization’, that ends up hurting efficiency in the GBP setting.
If we give every vector its own monomial and collapses to degree , the prover commits to coefficients, a saving of . We pay for this by splitting into and into : two extra commitments, leaving a net saving of group elements. For 4 vector commitments the saving is 96 bytes: 3 fewer group elements.
The split buys something else: it tightens the relation. A vector commitment opens as:
The term is slack the circuit cannot reach; the full arithmetization tolerates it, carrying it along in the terms of . However, the improved protocol below can ensure that all those terms are zero. The verifier evaluates the - and -sides separately and recombines them with a binding challenge , drawn after the evaluation point and before the opening. The improved protocol then proves the tighter relation , identical to the generalized Bulletproofs relation except that the vector commitments carry no component:
Public inputs are as in the arithmetization above; the target monomial is now .
Prover Verifier.
Sample and .
Commit:
Send .
Verifier Prover.
Sample and .
Send .
Both parties compute:
The challenge vectors
The weights
Prover Verifier.
Recall that the inner products to batch are:
Build with nonzero coefficients:
:
at
at
at for
at
:
at for
at
at
at
Corresponding to generators in and respectively.
; the target coefficient is .
Sample for every and set .
Send .
Verifier Prover.
Sample , then .
Send .
Prover Verifier.
Opening of (value , blinding ):
Opening of (vectors , blinding ):
with the blinding factors of the and the blinding factor of .
Send .
Verifier.
Set .
Define:
Compute:
Check:
Check:
Computational Special Soundness
We prove that the protocol is an argument of knowledge for . The theorem below establishes -computational special soundness; by Attema, Fehr and Klooß (TCC 2022) the Fiat-Shamir compiled argument then has tight security: under the hardness of discrete log over it is knowledge sound with knowledge error at most , where is the number of random-oracle queries.
Theorem.Assume has full column rank , i.e. a left inverse. The improved arithmetization is -computationally special sound for : from any -tree of accepting transcripts a deterministic extractor returns either a witness for , or a non-trivial discrete-log relation among the generators , i.e. a non-zero with .
The Tree of Accepting Transcripts. A -tree fixes the first message at the root and branches on the four challenges, recording each verifier message at its node: distinct children on ; under each, distinct children on , each with its ; under each, distinct children on ; under each, distinct leaves on . The opening is the never-sent final message decorating the leaves; drawn after , it may differ across the -children. Every root-to-leaf path is an accepting transcript. Index a -node by and its -leaves by , with challenges and leaf openings . Write for the two bracketed sums of the verifier’s (so ) and . The four arities are forced by the four interpolations below: for ; for the constraints together with the fresh offset degree; for the Hadamard rows; for the quadratic in .
Step 1: Interpolation in . Fix a node . The second verifier check at leaf reads:
with the single blinding supplied at the leaf. The leaves (with ) form a system whose unique solution pins both sides to four quadrant vectors and recovers the two blinders:
Where the quadrants are the interpolants:
And the blinding factors by the same interpolation, and . Here is a -mass sitting on the -side , and a -mass on the -side . The opening is sent after , so nothing yet forces ; this is the slack the offset must remove.
Step 2: The -Quadratic Forces the Cross Terms to Vanish. On this family the -th leaf satisfies and (leaves by construction; leaf either lies on the family — vectors and blinding alike — since its candidate below vanishes, or yields a relation). Hence:
The first check at leaf reads , with right-hand side determined before is drawn. Subtracting the leaf- instance, is a discrete-log relation on ; unless it is trivial — both and constant across the -children — the extractor returns it. With pinned to a single value, a map constant at the three distinct non-zero has ; hence at every node:
Forcing both the - and -terms to vanish requires three points, which fixes the -arity at .
Step 3: -Vandermonde Extraction. Fix and . Across the accepting transcripts that share them and use distinct , each quadrant is the evaluation at of a vector polynomial of degree whose coefficients are the per-degree representations of and . Inverting the Vandermonde in recovers, for each degree , a representation in three blocks:
And likewise for . Matching the layout of , the -blocks at degrees are , , , and (the mask with offset); the -blocks are the residual components at each degree.
Step 4: The Offset Eliminates the Residual Components. The residual -block of each -coefficient is the same fixed vector for every (up to the -rescaling), a property of the fixed commitments . We show every by descending induction on , from down.
Step 2 gives at every -child; interpolating across the points, every convolution coefficient of the two degree- quadrant polynomials vanishes. Take the coefficient at convolution degree . With the higher-degree residual components already zero by the induction hypothesis, the only nonzero term pairs the -block of the mask slot with ; that block is , with the fixed representation of , so the coefficient is:
Fix . The summand and the coefficient are independent of : the weights live at , and is fixed before . The term carries the fresh power, so is a polynomial vanishing at the distinct , and full-degree Vandermonde forces . (Pairwise differences would not suffice: distinct can share a -th power, which is why the -arity is , not .) Finally across the distinct is a Vandermonde in , so coordinate-wise.
Since every , the residual quadrant vanishes at every node, and each leaf’s inner product equals the honest convolution .
Step 5: Witness or Relation. The extractor reads a candidate witness off the Step-3 representations at a fixed reference (the first - and -children): from the -block of at degree , minus the public ; at degree ; and its blinder from the - and -blocks of at degree ; from the -block of at degree , minus the public and rescaled by ; and from the first check’s degree- coefficient across the first -children, via the precomputed left inverse of . It also forms a list of candidate discrete-log relations: differences of two recovered representations of the same group element, e.g. the representation of at minus the one at the reference , the high-degree coefficients, and the per-node consistency checks — the third -child’s opening and blinding against the family interpolated from the first two (Step 1), and the agreement of across the -children (Step 2). Each candidate satisfies by construction, being the difference of two representations of the same commitment; it is therefore a discrete-log relation, non-trivial exactly when .
The extractor outputs if , and otherwise the first non-zero candidate relation; we argue the contrapositive. If every candidate vanishes, the Step-3 representations and the per-node identities of Step 2 hold, and the residual components are pinned to the single family , so Step 4 forces every to zero. With the leaf openings now honest, cancelling in the first-check identity gives, for each :
Deaggregating over the distinct and the distinct (two Vandermondes) splits this into the first two relation clauses:
The vector-commitment clause is tight: since the residual components vanish, the degree- -block representation gives , with no -component. The scalar clause follows from the degree- recovery and the left inverse. All four clauses hold, so , contradicting the assumption. Hence a failing yields a non-zero candidate, which the extractor returns as the discrete-log relation.
Inner-Product Argument
The improved arithmetization reduces to the same inner-product relation , so it can reuse the inner-product argument above. Folding in the value yields the same relation , and the base case is unchanged; we change only the folding round, replacing the Laurent fold with a polynomial fold. This removes every field inversion.
Folding
Split each vector in half:
The prover sends the two cross terms:
The verifier replies with a challenge , and both sides fold the generators and statement to half length:
While the prover folds the witness the same way:
A simple calculation shows the relation is preserved, . The folded statement and witness then form a smaller instance of the same relation, . Observe that, unlike the original version, the field element may take any value in .
Base Case
After rounds the vectors have length one, leaving the base relation . The prover sends the two scalars , and the verifier accepts iff:
Below are listed the findings found during the engagement. High severity findings can be seen as
so-called
"priority 0" issues that need fixing (potentially urgently). Medium severity findings are most often
serious
findings that have less impact (or are harder to exploit) than high-severity findings. Low severity
findings
are most often exploitable in contrived scenarios, if at all, but still warrant reflection. Findings
marked
as informational are general comments that did not fit any of the other criteria.
Description. LinComb stores its weights in sparse vectors (WL, WR, WO, WV) and a sparse map (WCG).
Addition concatenates the operand’s entries onto these vectors rather than coalescing terms that share an index.
Observe that the size of the result is the sum of the sizes of the operands.
Adding a combination to itself doubles its length, so a loop that repeatedly accumulates a combination into itself grows the stored representation
exponentially in the number of iterations.
for _ in 0 .. n {
a = a + &a;
}
After n iterations the WL/WR/WO/WV vectors hold on the order of entries,
even though the combination is mathematically equivalent to a far smaller one.
Impact. This does not affect correctness or soundness; duplicated entries are accumulated additively when the constraint is evaluated, so the result is unchanged.
It’s a potential denial of service concern if the library is used in a setting where the adversary can cause the verifier to try and verify a circuit of this form.
Recommendation. If linear combinations are built from programmatic or untrusted inputs,
coalesce terms sharing an index during add/sub, or document that the sparse representation is not reduced and that callers
should make sure that the constructed circuit does not trigger this behavior.
Description. The prover forms its commitments , , and with the constant-time multiexp over witness-derived scalars (aL, aR, aO, the blinding vectors sL, sR, and the masks alpha, beta, rho).
let AI = {
let alg = witness.aL.0.iter().enumerate().map(|(i, aL)| (*aL, self.generators.g_bold(i)));
let arh = witness.aR.0.iter().enumerate().map(|(i, aR)| (*aR, self.generators.h_bold(i)));
let ah = core::iter::once((alpha, self.generators.h()));
let mut AI_terms = alg.chain(arh).chain(ah).collect::<Vec<_>>();
let AI = multiexp(&AI_terms);
AI_terms.zeroize();
AI
};
multiexp is the constant-time variant, but it is not constant time with respect to the scalars in the sense needed to hide the witness. It uses Pippenger’s algorithm, whose bucket accumulation indexes memory using bits of the secret scalars. Memory accesses are not constant time in general, so the access pattern leaks information about the scalars and cache attacks such as flush-and-reload apply. A scan over all buckets would close this, but that is not what the algorithm does.
The issue is in the upstream multiexp dependency used by the generalized-bulletproofs library. The upstream project has already fixed the issue, but the fixed version has not yet been published to crates.io.
Impact. This is not a verifier-soundness issue and is not exploitable from the proof bytes. It is a local side-channel risk: an attacker who can observe the prover’s memory access pattern, e.g. a co-tenant on the same machine, can learn information about the witness scalars. Whether this matters depends on whether the proofs are used in a witness-hiding setting and on the prover’s execution environment.
Recommendation. Document that proof generation assumes no local microarchitectural side-channel adversary and that witness privacy is out of scope for that environment, or update to the fixed upstream multiexp version once it is available.
Description. PedersenVectorCommitment::commit builds the input to multiexp in a temporary terms vector. The vector contains self.mask and the committed witness values from self.g_values.
impl<C: Ciphersuite> PedersenVectorCommitment<C> {
/// Commit to the vectors of values.
///
/// This function returns `None` if the amount of generators is less than the amount of values
/// within the relevant vector.
pub fn commit(&self, g_bold: &[C::G], h: C::G) -> Option<C::G>
where
C::G: ConditionallySelectable,
{
if g_bold.len() < self.g_values.len() {
None?;
}
let mut terms = vec![(self.mask, h)];
for pair in self.g_values.iter().copied().zip(g_bold.iter().copied()) {
terms.push(pair);
}
let res = multiexp(&terms);
terms.zeroize();
Some(res)
}
}
The terms vector is initialized with one element and then extended in the loop. Since the vector does not reserve space for all entries upfront, pushing witness pairs can force one or more reallocations. During a reallocation, Rust allocates a larger buffer, copies the existing elements into the new buffer, and releases the previous buffer. The old allocation is not zeroized before being released back to the allocator.
As a result, stale copies of self.mask and earlier self.g_values entries may remain in freed heap memory even though the final terms allocation is zeroized after multiexp returns. The explicit terms.zeroize() call is therefore incomplete for copies created by vector growth.
Impact. This does not affect the correctness, soundness, or hiding properties of the commitment scheme. It is a local secret-handling issue: witness scalars may remain in process memory longer than intended and could be recovered only if an attacker can read freed heap memory, inspect a memory dump, or otherwise obtain process memory. Zeroization is a best effort with Rust zeroize library, and the compiler or runtime may introduce other copies outside this function, but the vector reallocation pattern is a concrete and avoidable source of stale witness data.
Recommendation. It is recommended to pre-allocate enough space for terms before pushing witness values, so the vector does not reallocate during construction.
pub fn commit(&self, g_bold: &[C::G], h: C::G) -> Option<C::G>
where
C::G: ConditionallySelectable,
{
if g_bold.len() < self.g_values.len() {
None?;
}
let mut terms = Vec::with_capacity(self.g_values.len() + 1);
terms.push((self.mask, h));
for pair in self.g_values.iter().copied().zip(g_bold.iter().copied()) {
terms.push(pair);
}
let res = multiexp(&terms);
terms.zeroize();
Some(res)
}
Description. The fixed Generalized Bulletproofs relation treats the scalar-commitment weight matrix W_V as having full column rank, so each scalar commitment is individually bound by the constraints. ArithmeticCircuitStatement::new does not enforce this; it only checks that the indices referenced by each constraint are in bounds.
for constraint in &constraints {
if Some(generators.len()) <= constraint.highest_a_index {
Err(AcStatementError::ConstrainedNonExistentTerm)?;
}
if Some(C.len()) <= constraint.highest_c_index {
Err(AcStatementError::ConstrainedNonExistentVectorCommitment)?;
}
if Some(V.len()) <= constraint.highest_v_index {
Err(AcStatementError::ConstrainedNonExistentCommitment)?;
}
}
Both the prover and the verifier access the scalar commitments only through the z-aggregated combination of the W_V rows, so the proof binds the single combination rather than the individual commitments .
Observe that if W_V is not full column rank, any commitment in the right kernel of W_V is invisible to the verifier. The only code that checks each witness opening against its public commitment is gated behind #[cfg(debug_assertions)], so a release build does not catch a bogus opening for such a commitment.
This is a known restriction inherited from the original Bulletproofs relation, not specific to this implementation: a rank-deficient W_V binds only a linear combination of the commitments, so the individual openings cannot be extracted.
Consider two public commitments and , with a single constraint asserting their values sum to some target, . The weight row over is : rank one for two commitments, so . The proof binds only and , which is the opening of the sum ; it says nothing about and individually.
A prover therefore need not know either opening. Pick to be an arbitrary point with no known opening, then set for a chosen opening . Now , so the prover proves from the opening of the sum alone, while knowing no opening of or . The right kernel of W_V is spanned by : the prover can slide value and mask freely between the two commitments, including pushing onto a point it cannot open.
The extreme case is a self-cancelling column. The degenerate constraint V(0) - V(0) = 0 zeroes the entire column for that commitment, dropping it from every verifier equation; since the per-opening check is #[cfg(debug_assertions)]-gated, a release build then accepts a proof whose witness does not open the public commitment at all.
Impact. This is a defense-in-depth issue at the statement-construction boundary; it does not affect a caller using fixed, full-rank constraints. An integration that passes externally supplied Pedersen commitments in V and relies on the proof to establish knowledge of each opening, without itself ensuring W_V has full column rank, does not get that guarantee: the extractor cannot recover the openings of commitments outside the column span of W_V.
In particular, such a proof cannot stand in for a Schnorr-style proof of knowledge of a commitment opening, for example to spend a commitment by proving the prover can open it. When W_V is rank-deficient the proof establishes knowledge of an aggregate opening only, not of the individual commitments the application believes were proven.
Recommendation. If ArithmeticCircuitStatement::new does not enforce that W_V has full column rank, this limitation must be documented clearly: the proof binds only the aggregated combination , not the individual scalar commitment openings. Callers that rely on per-commitment binding, for example to prove knowledge of a commitment opening, must ensure W_V has full column rank themselves.
Description. Generators::new uses a local add_generator helper to reject identity points and track duplicate generators.
let mut set = HashSet::new();
let mut add_generator = |generator: &C::G| {
if bool::from(generator.is_identity()) {
return Err(GeneratorsError::IdentityPoint);
}
let bytes = generator.to_bytes();
Ok(!set.insert(bytes.as_ref().to_vec()))
};
debug_assert!(!add_generator(&g)?, "g was prior present in empty set");
if add_generator(&h)? {
Err(GeneratorsError::DuplicatedGenerator)?;
}
The call to add_generator(&g)? is placed inside debug_assert!. In release builds, debug_assert! expressions are compiled out, so this call is not evaluated. As a result, g is not checked for being the identity and is not inserted into the duplicate-detection set.
The following checks still validate h, g_bold, and h_bold, but they no longer compare those generators against g in release builds because g was never inserted into set. This makes the production validation weaker than the validation performed in debug builds.
Impact. Invalid generator sets involving g can be accepted in release builds even though they would be rejected in debug builds. The practical impact depends on how the library is integrated and whether generator inputs can be influenced by an untrusted party. At minimum, this creates a configuration validation mismatch.
Recommendation. Evaluate add_generator(&g)? unconditionally and return a normal error instead of relying on debug_assert!.
if add_generator(&g)? {
Err(GeneratorsError::DuplicatedGenerator)?;
}
The duplicate and identity checks are best-effort validation. They do not prove that the accepted bases are independently generated, or that no party knows discrete-log relations between them. It is recommended to document Generators::new as accepting only already-trusted independent bases, with these checks serving as additional defensive validation.
Description. IpStatement::prove checks that the prover has at least enough generators for the witness length rounded up to the next power of two.
// Ensure we have the necessary amount of generators
if witness.a.len() > ((usize::MAX >> 1) + 1) {
Err(IpProveError::IncorrectAmountOfGenerators)?;
}
if generators.g_bold_slice().len() < witness.a.len().next_power_of_two() {
Err(IpProveError::IncorrectAmountOfGenerators)?;
}
This permits generators.g_bold_slice().len() to be larger than witness.a.len().next_power_of_two(). However, the folding loop later treats g_bold.len() as the inner product dimension, while it splits the witness vectors using a.len().next_power_of_two() / 2.
// This interprets `g_bold.len()` as `n`
while g_bold.len() > 1 {
// Split a, b, g_bold, h_bold as needed for lines 20-24
let split_at = a.len().next_power_of_two() / 2;
let (a1, a2) = a.split(split_at);
let (b1, b2) = b.split(split_at);
let (g_bold1, g_bold2) = g_bold.split();
let (h_bold1, h_bold2) = h_bold.split();
let n_hat = g_bold1.len();
// Sanity
debug_assert_eq!(a1.len(), n_hat);
debug_assert!(a2.len() <= n_hat);
debug_assert_eq!(b1.len(), n_hat);
debug_assert!(b2.len() <= n_hat);
debug_assert_eq!(g_bold1.len(), n_hat);
debug_assert_eq!(g_bold2.len(), n_hat);
debug_assert_eq!(h_bold1.len(), n_hat);
debug_assert_eq!(h_bold2.len(), n_hat);
[...]
}
The debug assertions show that the intended invariant is stronger than the initial check: the generator vector length should match the padded witness length. If extra generators are accepted, the witness vectors and generator vectors can be folded under different dimensions. In release builds, the debug assertions are not enforced.
Impact. This is an input validation issue in the prover. Under the expected canonical parameters, where the generator vector length equals the padded witness length, there is no impact. If prove is called with an oversized generator vector, it can operate on inconsistent dimensions and may produce an invalid proof or fail later in the folding process instead of returning IpProveError::IncorrectAmountOfGenerators at the boundary.
Recommendation. It is recommended to require the generator vector length to be exactly witness.a.len().next_power_of_two() before entering the folding loop.
if generators.g_bold_slice().len() != witness.a.len().next_power_of_two() {
Err(IpProveError::IncorrectAmountOfGenerators)?;
}
Description. In the generalized Bulletproof construction, vector commitments are expressed over the same G_bold and H_bold generator vectors used by the proof. The length of these generator vectors is tied to the proof dimension, which is derived from the number of gates and padded to a power of two for the inner product argument.
This proof dimension can be larger than the logical vector length used by an application. For example, an application may intend to commit to a vector of length 3, while the generalized Bulletproof instance uses 4 generators after power-of-two padding.
The vector commitment API accepts an opening as long as there are enough generators for the provided values.
pub fn commit(&self, g_bold: &[C::G], h: C::G) -> Option<C::G>
where
C::G: ConditionallySelectable,
{
if g_bold.len() < self.g_values.len() {
None?;
}
let mut terms = vec![(self.mask, h)];
for pair in self.g_values.iter().copied().zip(g_bold.iter().copied()) {
terms.push(pair);
}
let res = multiexp(&terms);
terms.zeroize();
Some(res)
}
The generalized Bulletproof circuit then constrains only the coordinates that appear in the arithmetic constraints. This is expected for the H_bold part of the vector commitment, which represents auxiliary opening data that is not accessible from the circuit. However, the same issue can affect the G_bold part when the proof dimension is larger than the application’s intended vector length: tail coordinates past the logical vector length are not forced to be zero by default.
As a result, an application that reasons about only a fixed prefix of the vector commitment must ensure that every unused G_bold coordinate is either absent from the opening or explicitly constrained to zero. Otherwise, a prover can use non-zero values in the padded tail while still satisfying all constraints over the intended prefix.
Impact. This is an integration-level soundness concern. The proof remains valid for the generalized Bulletproof relation that was actually encoded, but that relation may be wider than the application intended. If an application treats a vector commitment as an exact-length commitment and does not constrain padded tail coordinates, the commitment can contain additional non-zero data outside the checked prefix.
Recommendation. It is recommended to document that generalized Bulletproof vector commitments are over the proof dimension, not necessarily the application-level vector length, and that callers are responsible for constraining unused tail coordinates.
Is is also recommended to make the logical vector length explicit wherever vector commitments are used in generalized Bulletproof statements. For exact-length vectors, add constraints forcing every padded G_bold coordinate beyond the logical length to be zero, or provide an API helper that automatically inserts these constraints.