Skip to content

test: add native fuzz targets for untrusted decoder boundaries - #29

Merged
piotr-roslaniec merged 4 commits into
devfrom
test/input-decoder-fuzz
Sep 29, 2026
Merged

piotr-roslaniec merged 4 commits into
devfrom
test/input-decoder-fuzz

Conversation

@piotr-roslaniec

@piotr-roslaniec piotr-roslaniec commented Sep 29, 2026 •

Copy link
Copy Markdown

Why

After PR #28 merged the proof input decoding and modulus bounds hardening into dev, this test-only PR adds durable native Go fuzz coverage for the three untrusted-input decoding boundaries the repo exposes. The goal is long-lived regression protection: any future refactor that subtly changes a decoder's contract or reopens a malformed-input hole will surface under the fuzz runner.

No production code, dependency, or doc changes.

Design

Every decoder argument is derived directly from native fuzz primitives (uint16 counts, byte fill values, flags). The production decoders care only about arity and empty/non-empty shape — big.Int.SetBytes accepts arbitrary byte syntax — so no custom framing parser, seed structs, or reader/writer scaffolding is introduced. Counts are capped (capFramedParts = 1024) before slice allocation so a tiny fuzz value cannot amplify into a large allocation. Each target classifies every generated shape against the decoder's contract and pins the rejection category, not wording. No key generation or expensive crypto on the hot path; all targets are deterministic and safe under parallel fuzz workers.

Targets and invariants

crypto — FuzzECPointGobDecode (crypto/ecpoint_fuzz_test.go)

  • Any successful GobDecode asserts: ValidateBasic (on-curve, non-identity), SameCurve(pt.Curve(), tss.EC()) (curve identity), and re-encode + re-decode reproduces an Equals-equal point on the same curve (catches swapped, mis-ordered, or sign-flipped coordinate decoding).
  • An errored decode must not leave a point that validates as on-curve. Malformed seeds: truncated stream, off-curve coordinates, empty input.

crypto/paillier — FuzzUnmarshalModProof (crypto/paillier/mod_proof_fuzz_test.go)

  • Args: (nx, na, nb, nz uint16, fill, emptyWS byte).
  • Exact PARAM_M arity on X/A/B/Z and non-empty W ⇒ must decode to a non-nil proof with W populated.
  • Any other shape (wrong arity, empty W) ⇒ must be rejected. Seeds: valid 80×4, X one short, Z one long, empty W.

crypto/mta — FuzzRangeProofAliceFromBytes (crypto/mta/range_proof_fuzz_test.go)

  • Args: (n uint16, fill, emptyAt byte) — emptyAt non-zero empties one interior part.
  • Exactly RangeProofAliceBytesParts (6) non-empty parts ⇒ must decode and pass ValidateBasic.
  • Wrong count or any emptied part ⇒ must be rejected. Seeds: valid 6-part, 5-part, 7-part, interior part emptied.

crypto/dlnproof — FuzzUnmarshalDLNProof (crypto/dlnproof/proof_fuzz_test.go)

  • Args: (na, nt uint16, fill byte).
  • Exactly Iterations alphas and Iterations ts parts ⇒ must decode to a non-nil proof.
  • Any other arity on either side ⇒ must be rejected. Seeds: valid 128×128, alphas one short, ts one long.

tss — FuzzParseWireMessage (tss/wire_fuzz_test.go)

  • Drives ParseWireMessage on wrapped content bytes.
  • Unparseable input (truncated, typeless, unknown type URL) ⇒ error, not a panic.
  • Any parse that succeeds asserts: a non-empty resolved proto type name; fresh-content round-trip — a fresh Any is built from the decoded content via anypb.New and re-marshaled, re-parsed, and the content must be proto.Equal to the first parse (exercises the decoded → re-encoded boundary, not a reuse of the original bytes); sender From identity and IsBroadcast flag round-trip.

Recommended fuzz commands (repo root)

go test ./crypto          -run '^$' -fuzz=FuzzECPointGobDecode        -fuzztime=1m
go test ./crypto/paillier -run '^$' -fuzz=FuzzUnmarshalModProof      -fuzztime=1m
go test ./crypto/mta      -run '^$' -fuzz=FuzzRangeProofAliceFromBytes -fuzztime=1m
go test ./crypto/dlnproof -run '^$' -fuzz=FuzzUnmarshalDLNProof      -fuzztime=1m
go test ./tss             -run '^$' -fuzz=FuzzParseWireMessage       -fuzztime=1m

Plain go test ./... runs the fuzz seed corpora without the fuzz runner; add -fuzz to activate a target. Quick corpus check: -fuzztime=5s.

Targeted head / files

  • head: test/input-decoder-fuzz
  • base: dev
  • files (all new, test-only):
    • crypto/ecpoint_fuzz_test.go
    • crypto/paillier/mod_proof_fuzz_test.go
    • crypto/mta/range_proof_fuzz_test.go
    • crypto/dlnproof/proof_fuzz_test.go
    • tss/wire_fuzz_test.go

Companion to the PR #28 hardening merge: durable native Go fuzz
coverage for the three untrusted-input decoding boundaries this repo
exposes, with semantic invariants on successful decodes and concrete
rejection categories on malformed input. No production code,
dependency, or doc changes.

- crypto: FuzzECPointGobDecode drives Gob-decoding an ECPoint.
  Valid/canonical seeds and any successful decode assert curve
  identity (SameCurve vs tss.EC()), on-curve hold, coordinate
  equality against the canonical seed, and gob round-trip
  stability. Malformed shapes (truncated, off-curve) assert a
  deterministic error, not a panic.
- crypto/paillier: FuzzUnmarshalModProof drives
  UnmarshalModProof through a u32/u16 framing parser that mirrors
  the wire section layout. A successful decode asserts exact
  PARAM_M part arity and per-element value equality against the
  decoded seed; short/long arities assert a specific rejection.
  Section counts are capped (capFramedParts) before allocation.
- crypto/mta: FuzzRangeProofAliceFromBytes drives
  RangeProofAliceFromBytes through its u16 framing parser. A
  successful decode asserts exact six-arity, value equality for
  the S/S1/S2 fields, ValidateBasic, and ValidateProof;
  non-six arities assert rejection. Counts capped before
  allocation.
- crypto/dlnproof: FuzzUnmarshalDLNProof drives UnmarshalDLNProof
  through a u16 framing parser. A successful decode asserts exact
  Iterations arity for both Alpha and T and per-element value
  equality; off-arity asserts rejection. Counts capped before
  allocation.
- tss: FuzzParseWireMessage drives ParseWireMessage on wrapped
  content bytes. Successful parses assert routing round-trip
  (From identity, IsBroadcast flag), TypeUrl registration, and
  proto round-trip stability; unknown type URLs and truncated
  inputs assert an error, not a panic.

Recommended fuzz commands (run from the repo root):

  go test ./crypto -run '^$' -fuzz=FuzzECPointGobDecode -fuzztime=1m
  go test ./crypto/paillier -run '^$' -fuzz=FuzzUnmarshalModProof -fuzztime=1m
  go test ./crypto/mta -run '^$' -fuzz=FuzzRangeProofAliceFromBytes -fuzztime=1m
  go test ./crypto/dlnproof -run '^$' -fuzz=FuzzUnmarshalDLNProof -fuzztime=1m
  go test ./tss -run '^$' -fuzz=FuzzParseWireMessage -fuzztime=1m

Plain `go test ./...` runs the deterministic seed tests without
fuzzing; the -fuzz flag activates the targets above.
Address review findings on the fuzz targets:
- tss/wire: use proto.Marshal for the unknown-type seed (not the
  method value); assert a fresh-content round-trip — a fresh Any is
  built from the decoded content via anypb.New and re-marshaled, so
  the invariant exercises the decoded -> re-encoded boundary instead
  of reusing the original Any bytes;
- paillier/mta/dlnproof: replace per-field SetBytes copy-restatement
  assertions with the decoder's contract classification — exact-arity
  non-empty shapes must decode to a non-nil/basic-valid proof, wrong
  arity or empty parts must be rejected;
- drop the companion Test...Seeds/semantics tests: Go executes fuzz
  seed corpora during ordinary `go test`, so they duplicated the seeds;
- remove now-dead helpers and imports.
The decoder fuzz targets built a second binary framing parser
(u16/u32 section layout, readers/writers, seed structs) around the
production decoders. SetBytes accepts arbitrary byte syntax, so that
framing only added an obscured second parser. Derive the decoder
arguments directly from native fuzz primitives instead:

- paillier: (nx, na, nb, nz uint16, fill, emptyWS byte) -> exact
  PARAM_M arity + non-empty W must decode to a populated proof;
  otherwise the decoder must reject.
- mta: (n uint16, fill, emptyAt byte) -> six non-empty parts must
  decode and ValidateBasic; a wrong count or emptied part must
  reject.
- dlnproof: (na, nt uint16, fill byte) -> exact Iterations arity on
  both sides must decode; otherwise reject.

Count caps (capFramedParts=1024) retained before allocation; the
valid/short/long/empty seed shapes are kept. ECPoint and wire
targets are unchanged.
@piotr-roslaniec
piotr-roslaniec merged commit 274a4f0 into dev Sep 29, 2026
4 checks passed
@piotr-roslaniec

Copy link
Copy Markdown
Author

Integration verification completed before merge into dev at 274a4f055e19c32fb217a0c598b8c3fa83722570.

  • Independent review found and fixed a compile blocker in the protobuf Any seed and an ineffective wire round-trip that reused the original encoded payload.
  • The final targets use native fuzz dimensions rather than custom framing parsers and contain no duplicate companion tests.
  • Ordinary go test seed execution passed for all five targets.
  • 10-second, two-worker fuzz campaigns passed for ECPoint Gob decode (152,914 executions), ModProof decode (116,331), RangeProofAlice decode (121,268), DLNProof decode (123,230), and wire-message parsing (336).
  • Inputs are capped before allocation; successful ECPoint and wire decodes assert semantic round-trip invariants, while proof decoders assert their arity/non-empty acceptance contracts.
  • GitHub Actions run 36627675834 passed vet/format, keygen units, and the full suite.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant