Skip to content

Commit 30b5365

Browse files
Test subgroup enforcement, improve ElGamal doc (OpenZeppelin#643)
1 parent 6bab442 commit 30b5365

3 files changed

Lines changed: 185 additions & 15 deletions

File tree

contracts/src/crypto/ElGamal.compact

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -34,16 +34,29 @@ pragma language_version >= 0.23.0;
3434
* scalar. Feeding a raw `persistentHash` output into `ecMulGenerator` would
3535
* occasionally exceed the Jubjub scalar field order and fault at runtime.
3636
*
37-
* @dev Subgroup membership. Every `JubjubPoint` reaching a curve operation is
38-
* in the Jubjub prime-order subgroup, including points supplied by a caller as
39-
* a witness or argument (e.g. a recipient `pk`, or the message point in
40-
* `encryptPoint`). The Midnight runtime enforces this at the ZK-constraint
37+
* @dev TRUST ASSUMPTION — subgroup membership. This is the module's most
38+
* load-bearing assumption. Every `JubjubPoint` reaching a curve operation is
39+
* assumed to be in the Jubjub prime-order subgroup, including points supplied by
40+
* a caller as a witness or argument (e.g. a recipient `pk`, or the message point
41+
* in `encryptPoint`). The Midnight runtime enforces this at the ZK-constraint
4142
* level: the embedded-curve gadget assigns points via cofactor clearing, so an
4243
* off-curve / low-order / mixed-order point makes the circuit unsatisfiable.
43-
* This module therefore performs NO in-circuit subgroup check (one is neither
44-
* needed nor expressible: `ecMul` by the subgroup order faults, and
45-
* `JubjubPoint` is opaque). The `ecNeg` identity and the homomorphic algebra
46-
* rely on this guarantee.
44+
* The module therefore performs NO in-circuit subgroup check: the runtime
45+
* already constrains membership (so it is unnecessary), and the direct test
46+
* `ℓ*P == O` is unavailable anyway because `ecMul` by `ℓ` faults (verified — `ℓ`
47+
* exceeds the valid scalar range, while `ℓ-1` is accepted). The `ecNeg` identity
48+
* (`(ℓ-1)*P = -P`) and the homomorphic algebra depend on this holding.
49+
*
50+
* Basis (and its limits): source-verified against `midnight-circuits` 6.0.0 —
51+
* the version vendored by the Midnight onchain-runtime we compile against —
52+
* specifically `ecc::native::edwards_chip`, where `assign` cofactor-clears the
53+
* point and `q_mem`'s `create_membership_gate` enforces on-curve, reached from
54+
* the ZKIR `ec_mul`/`ec_add` lowering via `ecc_from_parts`. This is NOT a
55+
* team-confirmed or audited guarantee: compactc and midnight-ledger are
56+
* unaudited, so treat it as a substantiated-but-unaudited assumption. It is
57+
* pinned against regression by `crypto/test/CurveRuntimeInvariants.test.ts` (a
58+
* runtime behavior canary) and MUST be re-verified in source on any Midnight
59+
* runtime / circuits version bump.
4760
*
4861
* @dev Weak keys. Encrypting under the identity point yields `c2 = m` with no
4962
* masking (the plaintext is exposed), and the identity is a valid subgroup
@@ -142,13 +155,15 @@ module ElGamal {
142155
/**
143156
* @description Negates a Jubjub point.
144157
*
145-
* @notice Implemented as scalar multiplication by `ORDER - 1` rather than the
146-
* obvious `ecMul(p, -1)`: the scalar `-1` computed in the `Field` type is
147-
* `BLS_modulus - 1`, which exceeds the Jubjub scalar field order and faults
148-
* `ecMul` ("failed to decode for built-in type EmbeddedFr"). `JubjubPoint`
149-
* is opaque (no coordinate access), so coordinate negation is unavailable.
150-
* The `(ORDER-1)*P = -P` identity holds only in the prime-order subgroup, but
151-
* that always applies here: the runtime constrains every assigned `JubjubPoint`
158+
* @notice Implemented as scalar multiplication by `ℓ-1` (via
159+
* `JUBJUB_SUBGROUP_ORDER_MINUS_ONE`) rather than the obvious `ecMul(p, -1)`:
160+
* the scalar `-1` computed in the `Field` type is `BLS_modulus - 1`, which
161+
* exceeds the Jubjub scalar field order and faults `ecMul` ("failed to decode
162+
* for built-in type EmbeddedFr"). (Coordinate negation `(-x, y)` via
163+
* `constructJubjubPoint`/`jubjubPointX`/`jubjubPointY` is an equivalent
164+
* alternative that does not depend on the subgroup assumption.) The
165+
* `(ℓ-1)*P = -P` identity holds only in the prime-order subgroup, but that
166+
* always applies here: the runtime constrains every assigned `JubjubPoint`
152167
* into the prime-order subgroup (the embedded-curve gadget assigns points via
153168
* cofactor clearing), so no in-subgroup precondition needs to be checked or
154169
* assumed. See the module-level subgroup note.
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import {
2+
constructJubjubPoint,
3+
type JubjubPoint,
4+
} from '@midnight-ntwrk/compact-runtime';
5+
import { describe, expect, it } from 'vitest';
6+
import { pureCircuits } from '../../../artifacts/MockCurveOps/contract/index.js';
7+
8+
// ---------------------------------------------------------------------------
9+
// RUNTIME-INVARIANTS REGRESSION TEST.
10+
//
11+
// The ElGamal module relies on every JubjubPoint reaching a curve operation
12+
// being in the prime-order subgroup. The Midnight runtime guarantees this at
13+
// the ZK-constraint level: the embedded-curve gadget (midnight-circuits
14+
// `ecc::native::edwards_chip`) assigns points via cofactor clearing. It
15+
// constrains the witnessed coordinates to a cofactor-cleared point, whose image
16+
// is exactly the prime-order subgroup, with `q_mem`'s membership gate
17+
// (-x^2 + y^2 = 1 + d*x^2*y^2) enforcing on-curve. So a point outside the
18+
// subgroup (off-curve, low-order, or mixed-order) makes the circuit
19+
// unsatisfiable, surfacing here as a runtime trap.
20+
//
21+
// This test pins that property. If a future runtime stops enforcing it, the
22+
// trap-expectations below fail loudly as a signal that the module's check-free
23+
// reliance on subgroup membership must be revisited.
24+
// ---------------------------------------------------------------------------
25+
26+
// Jubjub base field modulus q = BLS12-381 scalar field order.
27+
const Q =
28+
52435875175126190479447740508185965837690552500527637822603658699938581184513n;
29+
30+
// (0, q-1) = (0, -1): on-curve, order 2 -> NOT in the prime-order subgroup.
31+
const ORDER_2 = constructJubjubPoint(0n, Q - 1n);
32+
// (1, 1): off-curve (fails the twisted Edwards equation).
33+
const OFF_CURVE = constructJubjubPoint(1n, 1n);
34+
// arbitrary coords < q, unknown structure.
35+
const GARBAGE = constructJubjubPoint(12345n, 67890n);
36+
37+
// Classify a runtime call as a returned value or a trap.
38+
const classify = <T>(
39+
fn: () => T,
40+
): { ok: true; value: T } | { ok: false; err: string } => {
41+
try {
42+
return { ok: true, value: fn() };
43+
} catch (e) {
44+
return { ok: false, err: (e as Error).message };
45+
}
46+
};
47+
48+
const threw = (p: JubjubPoint, fn: (p: JubjubPoint) => unknown): boolean =>
49+
!classify(() => fn(p)).ok;
50+
51+
describe('JubjubPoint subgroup enforcement (runtime invariant)', () => {
52+
const inSubgroup = pureCircuits.genMul(5n); // generator-derived -> in subgroup
53+
54+
// MIXED-ORDER point of order 2*ℓ: an in-subgroup point plus the order-2 point.
55+
// In twisted Edwards, (x,y) + (0,-1) = (-x,-y), so this is just coordinate
56+
// negation of a real point. It is on-curve, NOT in the prime-order subgroup,
57+
// and (crucially) NOT a low-order point, so it should not hit any
58+
// addition-formula exception. This is the realistic attack vector.
59+
const MIXED = constructJubjubPoint(Q - inSubgroup.x, Q - inSubgroup.y);
60+
61+
it('FACT: a JubjubPoint is fabricable from arbitrary coordinates', () => {
62+
expect(ORDER_2).toEqual({ x: 0n, y: Q - 1n });
63+
});
64+
65+
// -------------------------------------------------------------------------
66+
// MIXED-ORDER point: the case that distinguishes "real subgroup enforcement"
67+
// from "incidental low-order formula exception".
68+
// -------------------------------------------------------------------------
69+
describe('mixed-order point (order 2*ℓ) — the decisive case', () => {
70+
it('TRAPS ecMul on a mixed-order point (genuine subgroup enforcement)', () => {
71+
expect(classify(() => pureCircuits.doEcMul(MIXED, 3n)).ok).toBe(false);
72+
});
73+
74+
it('TRAPS ecAdd on a mixed-order point', () => {
75+
expect(classify(() => pureCircuits.doEcAdd(MIXED, inSubgroup)).ok).toBe(
76+
false,
77+
);
78+
});
79+
});
80+
81+
// -------------------------------------------------------------------------
82+
// ecMul: does it reject non-subgroup / off-curve points?
83+
// -------------------------------------------------------------------------
84+
describe('ecMul input validation', () => {
85+
it('accepts an in-subgroup point', () => {
86+
expect(threw(inSubgroup, (p) => pureCircuits.doEcMul(p, 3n))).toBe(false);
87+
});
88+
89+
it('TRAPS on an on-curve order-2 point (off-subgroup)', () => {
90+
expect(threw(ORDER_2, (p) => pureCircuits.doEcMul(p, 3n))).toBe(true);
91+
});
92+
93+
it('TRAPS on an off-curve point', () => {
94+
expect(threw(OFF_CURVE, (p) => pureCircuits.doEcMul(p, 3n))).toBe(true);
95+
});
96+
97+
it('TRAPS on a garbage point', () => {
98+
expect(threw(GARBAGE, (p) => pureCircuits.doEcMul(p, 3n))).toBe(true);
99+
});
100+
});
101+
102+
// -------------------------------------------------------------------------
103+
// ecAdd: THE linchpin for encryptPoint (m flows through ecAdd, not ecMul).
104+
// -------------------------------------------------------------------------
105+
describe('ecAdd input validation', () => {
106+
it('accepts two in-subgroup points', () => {
107+
expect(
108+
classify(() => pureCircuits.doEcAdd(inSubgroup, inSubgroup)).ok,
109+
).toBe(true);
110+
});
111+
112+
it('TRAPS when an order-2 point is added', () => {
113+
expect(classify(() => pureCircuits.doEcAdd(ORDER_2, inSubgroup)).ok).toBe(
114+
false,
115+
);
116+
});
117+
118+
it('TRAPS when an off-curve point is added', () => {
119+
expect(
120+
classify(() => pureCircuits.doEcAdd(OFF_CURVE, inSubgroup)).ok,
121+
).toBe(false);
122+
});
123+
124+
it('TRAPS when a garbage point is added', () => {
125+
expect(classify(() => pureCircuits.doEcAdd(GARBAGE, inSubgroup)).ok).toBe(
126+
false,
127+
);
128+
});
129+
});
130+
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// SPDX-License-Identifier: MIT
2+
3+
// TEST-ONLY. NOT FOR PRODUCTION USE.
4+
// Exposes the raw Jubjub curve built-ins so the runtime-invariants regression
5+
// test (CurveRuntimeInvariants.test.ts) can assert that the Midnight runtime
6+
// rejects any point outside the prime-order subgroup. This pins a property the
7+
// ElGamal module's safety depends on (see the module's subgroup note): if a
8+
// future runtime stops enforcing subgroup membership on curve ops, that test
9+
// fails loudly. DO NOT deploy.
10+
11+
pragma language_version >= 0.23.0;
12+
13+
import CompactStandardLibrary;
14+
15+
export pure circuit doEcMul(p: JubjubPoint, k: Field): JubjubPoint {
16+
return ecMul(p, k);
17+
}
18+
19+
export pure circuit doEcAdd(a: JubjubPoint, b: JubjubPoint): JubjubPoint {
20+
return ecAdd(a, b);
21+
}
22+
23+
export pure circuit genMul(k: Field): JubjubPoint {
24+
return ecMulGenerator(k);
25+
}

0 commit comments

Comments
 (0)