Skip to content

S5: add the babysubgroup tag and require it in EscalarMulAny - #152

Draft
OBrezhniev wants to merge 1 commit into
fix/circomlib_buses_reviewfrom
fix/babysubgroup-tag
Draft

OBrezhniev wants to merge 1 commit into
fix/circomlib_buses_reviewfrom
fix/babysubgroup-tag

Conversation

@OBrezhniev

Copy link
Copy Markdown
Member

Stacked on #149 (fix/circomlib_buses_review); this PR shows only its own commits. Independent of the other stacked PRs unless stated. Full suite passes on the branch.

EscalarMulAny with the order-2 point (0,−1) returned the identity, silently wrong; its docstring said in prose that the point must be in the subgroup. Two granting templates (BabySubgroupClear, 8·p, 18 constraints; BabySubgroupCheck, p unchanged, 21 constraints via a witness q with 8q = p) and a requirement on EscalarMulAny. EdDSA verifiers get the tag for free from the cofactor clearing they already did; their constraint counts are unchanged. Merges clean with option A; one trivial README table conflict with option B.

Add the babysubgroup tag and require it in EscalarMulAny

EscalarMulAny stated in prose that its point must be in the prime order
subgroup. Nothing enforced it, and the consequence was concrete: for the
order-2 point (0,-1) it returned the identity, silently wrong, because
its zero-point substitution keys on pin.x == 0, which within the
subgroup is true of the identity alone and outside it is not.

A tag cannot ride through arithmetic here: a declared output carries
exactly the tags it declares, so BabyAdd's {babyedwards} output strips
anything extra (checked: a {babyedwards, babysubgroup} value passed
through a {babyedwards} output no longer satisfies a consumer requiring
both). The tag is therefore granted at the source and required at the
point of use.

Granting, in babyjub.circom:

BabySubgroupClear() 8pin. The curve has 8r points with r prime, so
this lands in the subgroup for any point of the
curve. It is the cofactor clearing the EdDSA
verifiers already did with three BabyDbl. 18
constraints.
BabySubgroupCheck() pin unchanged. p is in the subgroup iff p = 8q
for some point q of the curve, so the prover
supplies q = (8^-1 mod r)p as a witness, computed
by a witness-time double-and-add, and the template
checks q is on the curve and 8
q == p. No witness
exists otherwise. 21 constraints, against a full
scalar multiplication for checking r
p directly.

Requiring: EscalarMulAny's pin is {babyedwards, babysubgroup}. A caller
passing an untagged point gets error[T3001]: missing tags required by
input signal. Its docstring now says what depends on the property.

The three EdDSA verifiers use BabySubgroupClear in place of their three
BabyDbl and so obtain the tag for free. Their small-order-key rejection
tested (4A).x == 0 on an intermediate the template no longer exposes;
it now tests (8
A).x == 0 on the cleared point. Same predicate: (4A).x
== 0 iff 4
A is the identity or (0,-1) iff 8*A is the identity, and on
the tagged point x == 0 is exact. Constraint counts are unchanged:
eddsapedersen_test 17671, eddsapedersen_old_test 17703, eddsaposeidon
7373. escalarmulany_test gains the 21 of the check in its wrapper.

Tests: babysubgroup.js, 13 cases. The facts the templates rest on are
asserted against circomlibjs first (8r points; (0,-1) has order 2 and
is outside the subgroup; the identity is inside; the inv8 witness
works). Check accepts G, 2G, (r-1)G and the identity unchanged and
rejects (0,-1) and G + (0,-1); Clear returns 8
p in the subgroup for
points in and out of it and sends (0,-1) to the identity; both verifiers
still reject a key of order 2, Poseidon only when enabled. Dropping
q8 === pin fails exactly the two rejection cases. Full suite 131
passing. The correctness fuzz's (0,-1) row, previously two wrong
answers, is now three rejections; 807 legitimate cases, 0 wrong.

One thing the tests are built around: circomlibjs's wasm field never
returns from F.sqrt on a non-residue. An earlier version of this test
searched for random points of the curve that way and hung the whole
suite. Off-subgroup points are now built as G + (0,-1), with no roots.

🤖 Generated with Claude Code

https://claude.ai/code/session_01D1w729xzwemQigkf3PCisD

EscalarMulAny stated in prose that its point must be in the prime order
subgroup. Nothing enforced it, and the consequence was concrete: for the
order-2 point (0,-1) it returned the identity, silently wrong, because
its zero-point substitution keys on pin.x == 0, which within the
subgroup is true of the identity alone and outside it is not.

A tag cannot ride through arithmetic here: a declared output carries
exactly the tags it declares, so BabyAdd's {babyedwards} output strips
anything extra (checked: a {babyedwards, babysubgroup} value passed
through a {babyedwards} output no longer satisfies a consumer requiring
both). The tag is therefore granted at the source and required at the
point of use.

Granting, in babyjub.circom:

  BabySubgroupClear()  8*pin. The curve has 8*r points with r prime, so
                       this lands in the subgroup for any point of the
                       curve. It is the cofactor clearing the EdDSA
                       verifiers already did with three BabyDbl. 18
                       constraints.
  BabySubgroupCheck()  pin unchanged. p is in the subgroup iff p = 8*q
                       for some point q of the curve, so the prover
                       supplies q = (8^-1 mod r)*p as a witness, computed
                       by a witness-time double-and-add, and the template
                       checks q is on the curve and 8*q == p. No witness
                       exists otherwise. 21 constraints, against a full
                       scalar multiplication for checking r*p directly.

Requiring: EscalarMulAny's pin is {babyedwards, babysubgroup}. A caller
passing an untagged point gets error[T3001]: missing tags required by
input signal. Its docstring now says what depends on the property.

The three EdDSA verifiers use BabySubgroupClear in place of their three
BabyDbl and so obtain the tag for free. Their small-order-key rejection
tested (4*A).x == 0 on an intermediate the template no longer exposes;
it now tests (8*A).x == 0 on the cleared point. Same predicate: (4*A).x
== 0 iff 4*A is the identity or (0,-1) iff 8*A is the identity, and on
the tagged point x == 0 is exact. Constraint counts are unchanged:
eddsapedersen_test 17671, eddsapedersen_old_test 17703, eddsaposeidon
7373. escalarmulany_test gains the 21 of the check in its wrapper.

Tests: babysubgroup.js, 13 cases. The facts the templates rest on are
asserted against circomlibjs first (8*r points; (0,-1) has order 2 and
is outside the subgroup; the identity is inside; the inv8 witness
works). Check accepts G, 2G, (r-1)G and the identity unchanged and
rejects (0,-1) and G + (0,-1); Clear returns 8*p in the subgroup for
points in and out of it and sends (0,-1) to the identity; both verifiers
still reject a key of order 2, Poseidon only when enabled. Dropping
q8 === pin fails exactly the two rejection cases. Full suite 131
passing. The correctness fuzz's (0,-1) row, previously two wrong
answers, is now three rejections; 807 legitimate cases, 0 wrong.

One thing the tests are built around: circomlibjs's wasm field never
returns from F.sqrt on a non-residue. An earlier version of this test
searched for random points of the curve that way and hung the whole
suite. Off-subgroup points are now built as G + (0,-1), with no roots.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1w729xzwemQigkf3PCisD
@OBrezhniev
OBrezhniev force-pushed the fix/circomlib_buses_review branch from 7198ad7 to d5f158d Compare September 29, 2026 20:08
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