Skip to content

Commit ee8c879

Browse files
authored
Remediate security audit findings with regression tests (#8)
Addresses the valid findings from the gnark-APK-Proofs security audit (Dr. Cyprian Sakwa, 2026-04-22) across all layers, with tests. Circuit / Go FFI: - Enforce on-curve + prime-order subgroup checks on public keys at the FFI trust boundary (new apk.ParseG1; findings 1, 3, 14). All-zero is treated as the identity padding point. - Validate participation indices (range + uniqueness) before deriving the bitlist, and require exact witness length (findings 5, 10, 12, 13). This is fail-loud input hygiene; the key set is committed and the bitlist/ExpectedApk are public, so it is not a soundness control. - Document the 5-limb bitlist encoding and the validation trust boundary (findings 2; corrected misleading apk.go comments for 1, 3). Rust prover: - Validate every public key on-curve + in subgroup, reject duplicate indices (findings 15, 16). - Check FFI output buffers for null/zero length before dereferencing raw pointers (finding 18). - parse_public_inputs returns a structured error instead of panicking (findings 19, 20). Rust PLONK verifier: - Subgroup checks on all deserialized G1/G2 points, validate decompressed points (findings 25, 26). - Validate proof structure lengths, domain-size power-of-two, and Lagrange index bounds (findings 27, 30, 34). - Simplify batch-random field reduction to remove a potential panic (finding 31); remove the misleadingly-named endianness trait (finding 28); add RFC 9380 references (findings 32, 33). Integration test: - Replace cross-version mem::transmute with canonical-serialization conversion between arkworks 0.4 (w3f-bls) and 0.5 (finding 35). Build system: - Reproducible Go build (-trimpath, -buildvcs=false), cfg-gated system linking, and go.mod/go.sum rebuild tracking (findings 43, 46, 47). Solidity: - Document the EIP-2537 trust boundary and stateless/replay model (findings 50, 53). Regression tests: 9 Go (apk), 10 Rust prover, 8 Rust verifier.
1 parent ce18b5c commit ee8c879

13 files changed

Lines changed: 852 additions & 99 deletions

File tree

circuits/apk/apk.go

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,17 @@ type ApkProofCircuit struct {
6868

6969
// Define defines the circuit constraints
7070
func (circuit *ApkProofCircuit) Define(api frontend.API) error {
71-
// Decompose bitlist into individual bits
71+
// Decompose the bitlist into 1024 individual participation bits.
72+
//
73+
// Bitlist encoding (audit finding 2): the 1024-bit participation set is packed
74+
// into 5 field-element limbs, little-endian within each limb:
75+
// - Bitlist[0..3]: 250 bits each -> indices 0..999
76+
// - Bitlist[4]: 24 bits -> indices 1000..1023
77+
// Bit i of limb k maps to validator index (k*250 + i) for k<4, and (1000 + i)
78+
// for k==4. api.ToBinary(x, n) constrains x < 2^n and enforces the canonical
79+
// bit decomposition, so out-of-range limb values are rejected in-circuit.
80+
// The Go/Rust witness builders MUST use this exact mapping
81+
// (see apk.CreateBitlistFromIndices); it is the single canonical source.
7282
var bits []frontend.Variable
7383
for i := range len(circuit.Bitlist) {
7484
if i == 4 {
@@ -95,9 +105,15 @@ func (circuit *ApkProofCircuit) Define(api frontend.API) error {
95105
apk := &seed
96106

97107
// Hash public keys and aggregate in a single pass.
98-
// No in-circuit on-curve or subgroup checks are needed: these are performed
99-
// at validator registration time (PoP assumption), and the Poseidon2
100-
// commitment binds the prover to exactly those validated keys.
108+
//
109+
// On-curve and prime-order subgroup validity of every public key are enforced
110+
// outside the proving system, at the FFI trust boundary in apk.ParseG1 (audit
111+
// findings 1, 3, 14), and independently in the Rust prover before serialization
112+
// (finding 15). In-circuit subgroup checks over 1024 emulated BLS12-381 points
113+
// are prohibitively expensive and are intentionally not performed here; the
114+
// Poseidon2 commitment then binds the prover to exactly the validated key set.
115+
// Proof of Possession at registration covers secret-key ownership only — it is
116+
// a separate guarantee from the algebraic point validation done in ParseG1.
101117
for i := range 1024 {
102118
hasher.Write(circuit.PublicKeys[i].X.Limbs...)
103119
hasher.Write(circuit.PublicKeys[i].Y.Limbs...)

circuits/apk/validation.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// Copyright 2026 Polytope Labs.
2+
// SPDX-License-Identifier: Apache-2.0
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package apk
17+
18+
import (
19+
"fmt"
20+
21+
bls12381 "github.com/consensys/gnark-crypto/ecc/bls12-381"
22+
"github.com/consensys/gnark-crypto/ecc/bls12-381/fp"
23+
)
24+
25+
// NumValidators is the fixed validator-set size of the APK circuit.
26+
const NumValidators = 1024
27+
28+
// G1UncompressedSize is the byte length of a G1 point in the FFI/Solidity wire
29+
// format: X (48 bytes big-endian) || Y (48 bytes big-endian).
30+
const G1UncompressedSize = 96
31+
32+
// ParseG1 deserializes a single G1 point from the 96-byte FFI wire format
33+
// (X || Y, big-endian) and validates it at the trust boundary.
34+
//
35+
// This is the first point at which untrusted public keys enter the system, so
36+
// it is where cryptographic validity is enforced (audit findings 1, 3, 14):
37+
//
38+
// - The all-zero encoding is the canonical point at infinity, used to pad the
39+
// validator set to NumValidators. It is accepted without curve checks,
40+
// matching the Rust prover's identity encoding (g1_to_gnark_bytes).
41+
// - Any other point must satisfy the curve equation y² = x³ + 4 and lie in the
42+
// prime-order subgroup. Proof of Possession at registration proves ownership
43+
// of the secret key but does NOT imply on-curve or subgroup membership —
44+
// those are independent algebraic checks enforced here.
45+
func ParseG1(data []byte) (bls12381.G1Affine, error) {
46+
var pt bls12381.G1Affine
47+
if len(data) != G1UncompressedSize {
48+
return pt, fmt.Errorf("G1 point must be %d bytes, got %d", G1UncompressedSize, len(data))
49+
}
50+
51+
// All-zero bytes denote the point at infinity (the zero value of G1Affine).
52+
allZero := true
53+
for _, b := range data {
54+
if b != 0 {
55+
allZero = false
56+
break
57+
}
58+
}
59+
if allZero {
60+
return pt, nil
61+
}
62+
63+
var x, y fp.Element
64+
x.SetBytes(data[0:48])
65+
y.SetBytes(data[48:96])
66+
pt.X = x
67+
pt.Y = y
68+
69+
if !pt.IsOnCurve() {
70+
return pt, fmt.Errorf("G1 point not on curve")
71+
}
72+
if !pt.IsInSubGroup() {
73+
return pt, fmt.Errorf("G1 point not in prime-order subgroup")
74+
}
75+
return pt, nil
76+
}
77+
78+
// ValidateParticipationIndices checks that every participation index is within
79+
// [0, numKeys) and that there are no duplicates (audit findings 5, 12, 16).
80+
//
81+
// This is fail-loud input validation, not a soundness control. The circuit binds
82+
// the key set via PublicKeysCommitment and exposes Bitlist/ExpectedApk as public
83+
// inputs, so a malformed index list cannot make a verifier accept a wrong APK.
84+
// And the bitlist and the aggregation set are derived from the same indices with
85+
// the same range filter (and both are idempotent), so they cannot diverge or
86+
// double-count. What this prevents is the prover *silently* building a proof for
87+
// a different participation set than the caller intended: the previous helpers
88+
// dropped out-of-range indices and collapsed duplicates without complaint.
89+
func ValidateParticipationIndices(indices []int, numKeys int) error {
90+
seen := make(map[int]bool, len(indices))
91+
for _, idx := range indices {
92+
if idx < 0 || idx >= numKeys {
93+
return fmt.Errorf("participant index %d out of range [0, %d)", idx, numKeys)
94+
}
95+
if seen[idx] {
96+
return fmt.Errorf("duplicate participant index %d", idx)
97+
}
98+
seen[idx] = true
99+
}
100+
return nil
101+
}

circuits/apk/validation_test.go

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
// Copyright 2026 Polytope Labs.
2+
// SPDX-License-Identifier: Apache-2.0
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package apk
17+
18+
import (
19+
"strings"
20+
"testing"
21+
22+
bls12381 "github.com/consensys/gnark-crypto/ecc/bls12-381"
23+
"github.com/consensys/gnark-crypto/ecc/bls12-381/fp"
24+
)
25+
26+
// encodeG1 serializes a point into the 96-byte FFI wire format (X || Y, big-endian).
27+
func encodeG1(pt bls12381.G1Affine) []byte {
28+
xb := pt.X.Bytes()
29+
yb := pt.Y.Bytes()
30+
out := make([]byte, G1UncompressedSize)
31+
copy(out[0:48], xb[:])
32+
copy(out[48:96], yb[:])
33+
return out
34+
}
35+
36+
// onCurveNotInSubgroup deterministically finds a point that satisfies the curve
37+
// equation but lies outside the prime-order subgroup (BLS12-381 G1 has a large
38+
// cofactor, so almost every on-curve point qualifies).
39+
func onCurveNotInSubgroup(t *testing.T) bls12381.G1Affine {
40+
t.Helper()
41+
var x, one, four fp.Element
42+
x.SetUint64(2)
43+
one.SetUint64(1)
44+
four.SetUint64(4)
45+
for i := 0; i < 1000; i++ {
46+
var x3, rhs, y fp.Element
47+
x3.Square(&x).Mul(&x3, &x) // x³
48+
rhs.Add(&x3, &four) // x³ + 4
49+
if y.Sqrt(&rhs) != nil {
50+
var pt bls12381.G1Affine
51+
pt.X = x
52+
pt.Y = y
53+
if pt.IsOnCurve() && !pt.IsInSubGroup() {
54+
return pt
55+
}
56+
}
57+
x.Add(&x, &one)
58+
}
59+
t.Fatal("failed to construct an on-curve, non-subgroup point")
60+
return bls12381.G1Affine{}
61+
}
62+
63+
// TestParseG1_ValidGenerator confirms a canonical subgroup point round-trips.
64+
func TestParseG1_ValidGenerator(t *testing.T) {
65+
_, _, g1, _ := bls12381.Generators()
66+
pt, err := ParseG1(encodeG1(g1))
67+
if err != nil {
68+
t.Fatalf("generator rejected: %v", err)
69+
}
70+
if !pt.Equal(&g1) {
71+
t.Fatal("parsed point does not equal generator")
72+
}
73+
}
74+
75+
// TestParseG1_Identity confirms the all-zero padding encoding is accepted as
76+
// the point at infinity (audit finding 24 / identity encoding).
77+
func TestParseG1_Identity(t *testing.T) {
78+
pt, err := ParseG1(make([]byte, G1UncompressedSize))
79+
if err != nil {
80+
t.Fatalf("identity rejected: %v", err)
81+
}
82+
if !pt.IsInfinity() {
83+
t.Fatal("all-zero encoding did not decode to point at infinity")
84+
}
85+
}
86+
87+
// TestParseG1_OffCurve rejects a point that fails the curve equation (finding 14).
88+
func TestParseG1_OffCurve(t *testing.T) {
89+
_, _, g1, _ := bls12381.Generators()
90+
bad := g1
91+
var one fp.Element
92+
one.SetUint64(1)
93+
bad.Y.Add(&bad.Y, &one) // perturb Y so y² ≠ x³ + 4
94+
_, err := ParseG1(encodeG1(bad))
95+
if err == nil || !strings.Contains(err.Error(), "not on curve") {
96+
t.Fatalf("expected on-curve rejection, got %v", err)
97+
}
98+
}
99+
100+
// TestParseG1_NotInSubgroup rejects an on-curve point outside the prime-order
101+
// subgroup (audit findings 1, 3 enforced at the FFI boundary).
102+
func TestParseG1_NotInSubgroup(t *testing.T) {
103+
pt := onCurveNotInSubgroup(t)
104+
if !pt.IsOnCurve() {
105+
t.Fatal("test point should be on curve")
106+
}
107+
_, err := ParseG1(encodeG1(pt))
108+
if err == nil || !strings.Contains(err.Error(), "subgroup") {
109+
t.Fatalf("expected subgroup rejection, got %v", err)
110+
}
111+
}
112+
113+
// TestParseG1_WrongLength rejects malformed inputs.
114+
func TestParseG1_WrongLength(t *testing.T) {
115+
for _, n := range []int{0, 48, 95, 97, 192} {
116+
if _, err := ParseG1(make([]byte, n)); err == nil {
117+
t.Fatalf("expected error for %d-byte input", n)
118+
}
119+
}
120+
}
121+
122+
func TestValidateParticipationIndices_Valid(t *testing.T) {
123+
if err := ValidateParticipationIndices([]int{0, 1, 250, 999, 1000, 1023}, NumValidators); err != nil {
124+
t.Fatalf("valid indices rejected: %v", err)
125+
}
126+
if err := ValidateParticipationIndices(nil, NumValidators); err != nil {
127+
t.Fatalf("empty indices rejected: %v", err)
128+
}
129+
}
130+
131+
func TestValidateParticipationIndices_OutOfRange(t *testing.T) {
132+
for _, idx := range []int{-1, NumValidators, NumValidators + 5} {
133+
err := ValidateParticipationIndices([]int{0, idx}, NumValidators)
134+
if err == nil || !strings.Contains(err.Error(), "out of range") {
135+
t.Fatalf("index %d: expected out-of-range error, got %v", idx, err)
136+
}
137+
}
138+
}
139+
140+
func TestValidateParticipationIndices_Duplicate(t *testing.T) {
141+
err := ValidateParticipationIndices([]int{3, 7, 3}, NumValidators)
142+
if err == nil || !strings.Contains(err.Error(), "duplicate") {
143+
t.Fatalf("expected duplicate error, got %v", err)
144+
}
145+
}
146+
147+
// TestBitlistMapping_EdgeIndices locks the index→bit mapping at limb boundaries
148+
// (audit finding 6: missing edge-case coverage) by round-tripping through decode.
149+
func TestBitlistMapping_EdgeIndices(t *testing.T) {
150+
cases := [][]int{
151+
{0}, // first bit
152+
{249}, // last bit of limb 0
153+
{250}, // first bit of limb 1
154+
{999}, // last bit of limb 3
155+
{1000}, // first bit of limb 4
156+
{1023}, // last valid bit
157+
{0, 1023}, // both extremes
158+
{249, 250}, // limb 0/1 boundary
159+
{999, 1000}, // limb 3/4 boundary
160+
}
161+
for _, want := range cases {
162+
bitlist := CreateBitlistFromIndices(want)
163+
got := DecodeBitlist(bitlist)
164+
if len(got) != len(want) {
165+
t.Fatalf("indices %v: decoded %v", want, got)
166+
}
167+
set := make(map[int]bool)
168+
for _, i := range got {
169+
set[i] = true
170+
}
171+
for _, i := range want {
172+
if !set[i] {
173+
t.Fatalf("indices %v: missing %d in decoded %v", want, i, got)
174+
}
175+
}
176+
}
177+
}

0 commit comments

Comments
 (0)