@coordination-network/circuits 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Nargo.toml CHANGED
@@ -5,8 +5,9 @@ authors = ["Coordination Network"]
5
5
 
6
6
  [dependencies]
7
7
  poseidon = { git = "https://github.com/noir-lang/poseidon", tag = "v0.3.0" }
8
- # ponytail: floating on master — the latest tag (v0.1.3) still uses the removed `ec` stdlib
9
- # module, and nargo git deps only accept `tag`/branch, no commit pinning. Pin once upstream tags.
10
- eddsa = { git = "https://github.com/noir-lang/eddsa", tag = "master" }
8
+ # Baby Jubjub EdDSA verification lives in src/eddsa.nr rather than noir-lang/eddsa: the upstream
9
+ # verifier passes its challenge through noir-edwards v0.2.5's `ScalarField::<64>::from`, which
10
+ # does not constrain the decomposition to its input. Same version upstream eddsa (7e206c9) uses.
11
+ edwards = { git = "https://github.com/noir-lang/noir-edwards", tag = "v0.2.5" }
11
12
  binary_merkle_root = { git = "https://github.com/zk-kit/zk-kit.noir", tag = "binary-merkle-root-v0.0.2", directory = "packages/binary-merkle-root" }
12
13
  lean_imt_plus = { git = "https://github.com/zk-kit/zk-kit.noir", tag = "lean-imt-plus-v0.0.1", directory = "packages/lean-imt-plus" }
package/README.md CHANGED
@@ -68,6 +68,18 @@ both roots against their trusted on-chain registries.
68
68
  8. Prove that commitment is absent from a private LeanIMT+ witness and return
69
69
  the resulting root publicly.
70
70
 
71
+ The issuer, holder and revocation signature checks rely on cofactored EdDSA
72
+ verification (the key is multiplied by 8).
73
+
74
+ The verifier is `src/eddsa.nr`, a copy of `noir-lang/eddsa` with one added
75
+ constraint. Upstream converts the Poseidon challenge with `noir-edwards`
76
+ v0.2.5's `ScalarField::<64>::from`, which takes its WNAF slices from an
77
+ unconstrained hint and only range-checks them below the field modulus, never
78
+ against the input. A prover could replace the challenge with one and verify
79
+ `S = 0, R8 = -8A` for any key. The local copy asserts that the slices
80
+ reconstruct the hash. A `noir-edwards` bump must re-check that conversion
81
+ before this copy is dropped or the dependency rebuilt.
82
+
71
83
  The canonical empty revocation root is zero and requires a zeroed depth-zero
72
84
  witness.
73
85
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coordination-network/circuits",
3
- "version": "0.0.1",
3
+ "version": "0.1.0",
4
4
  "description": "The coordination network's Noir circuits: the credential presentation circuit proving an approved issuer's signature, the credential policy, holder, device and revocation key possession, and revocation non-membership.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -34,13 +34,13 @@
34
34
  "compile": "nargo compile",
35
35
  "execute": "nargo execute",
36
36
  "fixtures": "tsx scripts/generate-fixtures.ts",
37
- "test": "nargo test",
37
+ "test": "nargo test && tsx scripts/test-constraints.ts",
38
38
  "test:constraints": "tsx scripts/test-constraints.ts"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@noble/curves": "^1.9.7",
42
42
  "@noble/hashes": "^1.8.0",
43
- "@semaphore-protocol/identity": "^4.14.3",
43
+ "@semaphore-protocol/identity": "^4.14.4",
44
44
  "poseidon-lite": "0.3.0",
45
45
  "tsx": "^4.23.0",
46
46
  "typescript": "^6.0.3",
package/src/eddsa.nr ADDED
@@ -0,0 +1,175 @@
1
+ // Baby Jubjub EdDSA verification, adapted from noir-lang/eddsa at
2
+ // 7e206c95f850346a84faf78e3430cd2bc3f6aabf (Apache-2.0), with the challenge
3
+ // scalar bound to the hash it represents.
4
+ //
5
+ // noir-edwards v0.2.5's `ScalarField::<64>::from` takes its WNAF slices from an
6
+ // unconstrained hint and, unlike the `N < 64` branch, never checks that they
7
+ // reconstruct the input: it only range-checks them below the field modulus. A
8
+ // prover could therefore replace the challenge `h` with any scalar (e.g. one),
9
+ // and `S = 0, R8 = -8A` would then verify for any key and message. Upstream
10
+ // eddsa passes the challenge through that conversion unchanged, so this copy
11
+ // adds the missing equality. Drop it once noir-edwards constrains the
12
+ // reconstruction itself.
13
+
14
+ use std::default::Default;
15
+ use std::hash::Hasher;
16
+
17
+ use edwards::{bjj::BabyJubJub, Curve, CurveTrait, ScalarField};
18
+
19
+ // Baby Jubjub subgroup order
20
+ global SUBORDER: Field =
21
+ 2736030358979909402780800718157159386076813972158567259200215660948447373041;
22
+
23
+ // Generator * 8
24
+ global BASE8_X: Field =
25
+ 5299619240641551281634865583518297030282874472190772894086521144482721001553;
26
+ global BASE8_Y: Field =
27
+ 16950150798460657717958625567821834550301663161624707787222815936182638968203;
28
+
29
+ pub fn eddsa_verify<H>(
30
+ pub_key_x: Field,
31
+ pub_key_y: Field,
32
+ signature_s: Field,
33
+ signature_r8_x: Field,
34
+ signature_r8_y: Field,
35
+ message: Field,
36
+ ) -> bool
37
+ where
38
+ H: Hasher + Default,
39
+ {
40
+ // Verifies by testing:
41
+ // S * B8 = R8 + H(R8, A, m) * A8
42
+
43
+ // BabyJubJub::new asserts the point is on the curve
44
+ let pub_key = BabyJubJub::new(pub_key_x, pub_key_y);
45
+ let signature_r8 = BabyJubJub::new(signature_r8_x, signature_r8_y);
46
+ // Ensure S < Subgroup Order
47
+ assert(signature_s.lt(SUBORDER));
48
+ // Calculate the h = H(R, A, msg)
49
+ let mut hasher = H::default();
50
+ hasher.write(signature_r8_x);
51
+ hasher.write(signature_r8_y);
52
+ hasher.write(pub_key_x);
53
+ hasher.write(pub_key_y);
54
+ hasher.write(message);
55
+ let hash: Field = hasher.finish();
56
+ // Calculate second part of the right side: right2 = h*8*A
57
+ // Multiply by 8 by doubling 3 times. This also ensures that the result is in the subgroup.
58
+ let pub_key_mul_2 = pub_key.dbl();
59
+ let pub_key_mul_4 = pub_key_mul_2.dbl();
60
+ let pub_key_mul_8 = pub_key_mul_4.dbl();
61
+ // We check that A8 is not zero.
62
+ assert(!pub_key_mul_8.is_zero());
63
+
64
+ verify_with_challenge(
65
+ pub_key_mul_8,
66
+ signature_r8,
67
+ signature_s,
68
+ hash,
69
+ ScalarField::<64>::from(hash),
70
+ )
71
+ }
72
+
73
+ // Split from `eddsa_verify` so tests can supply the challenge decomposition a
74
+ // hostile prover's hint would, which `ScalarField`'s private fields otherwise prevent.
75
+ fn verify_with_challenge(
76
+ pub_key_mul_8: BabyJubJub,
77
+ signature_r8: BabyJubJub,
78
+ signature_s: Field,
79
+ hash: Field,
80
+ hash_scalar: ScalarField<64>,
81
+ ) -> bool {
82
+ // `from` range-checks the slices below the field modulus; binding their
83
+ // (constrained) reconstruction to `hash` makes the decomposition unique.
84
+ assert(
85
+ Into::<Field>::into(hash_scalar) == hash,
86
+ "eddsa challenge decomposition does not match its hash",
87
+ );
88
+ // Compute the right side: R8 + h * A8
89
+ let right = signature_r8 + pub_key_mul_8.mul(hash_scalar);
90
+ // Calculate left side of equation left = S * B8
91
+ let base8: BabyJubJub = Curve { x: BASE8_X, y: BASE8_Y };
92
+ let left = base8.mul(ScalarField::<63>::from(signature_s));
93
+
94
+ left == right
95
+ }
96
+
97
+ mod tests {
98
+ use edwards::{bjj::BabyJubJub, Curve, CurveTrait, ScalarField};
99
+ use poseidon::poseidon::PoseidonHasher;
100
+ use poseidon::poseidon2::Poseidon2Hasher;
101
+
102
+ use std::hash::Hasher;
103
+
104
+ use super::{BASE8_X, BASE8_Y, eddsa_verify, verify_with_challenge};
105
+
106
+ // Upstream noir-lang/eddsa's own test vectors.
107
+ #[test]
108
+ fn upstream_vectors() {
109
+ let msg = 789;
110
+ let pub_key_a = BabyJubJub::one().mul(ScalarField::<63>::from(123));
111
+ let pub_key_b = BabyJubJub::one().mul(ScalarField::<63>::from(456));
112
+ let base8: BabyJubJub = Curve { x: BASE8_X, y: BASE8_Y };
113
+ let r8_a = base8.mul(ScalarField::<63>::from(
114
+ 1414770703199880747815475415092878800081323795074043628810774576767372531818,
115
+ ));
116
+ let r8_b = base8.mul(ScalarField::<63>::from(
117
+ 571799555715456644614141527517766533395606396271089506978608487688924659618,
118
+ ));
119
+ let s_a = 30333430637424319196043722294837632681219980330991241982145549329256671548;
120
+ let s_b = 1646085314320208098241070054368798527940102577261034947654839408482102287019;
121
+
122
+ assert(eddsa_verify::<PoseidonHasher>(pub_key_a.x, pub_key_a.y, s_a, r8_a.x, r8_a.y, msg));
123
+ assert(eddsa_verify::<PoseidonHasher>(pub_key_b.x, pub_key_b.y, s_b, r8_b.x, r8_b.y, msg));
124
+ assert(!eddsa_verify::<PoseidonHasher>(pub_key_a.x, pub_key_a.y, s_b, r8_b.x, r8_b.y, msg));
125
+ assert(
126
+ !eddsa_verify::<PoseidonHasher>(pub_key_a.x, pub_key_a.y, s_a, r8_a.x, r8_a.y, msg + 1),
127
+ );
128
+ assert(
129
+ !eddsa_verify::<Poseidon2Hasher>(pub_key_a.x, pub_key_a.y, s_a, r8_a.x, r8_a.y, msg),
130
+ );
131
+ }
132
+
133
+ // The forgery from the unconstrained-challenge finding: S = 0 and R8 = -8A
134
+ // need no secret key, and satisfy the curve equation once the challenge is one.
135
+ fn forgery() -> (BabyJubJub, BabyJubJub, Field) {
136
+ let pub_key = BabyJubJub::one().mul(ScalarField::<63>::from(123));
137
+ let pub_key_mul_8 = pub_key.dbl().dbl().dbl();
138
+ let signature_r8 = -pub_key_mul_8;
139
+ let mut hasher = PoseidonHasher::default();
140
+ hasher.write(signature_r8.x);
141
+ hasher.write(signature_r8.y);
142
+ hasher.write(pub_key.x);
143
+ hasher.write(pub_key.y);
144
+ hasher.write(42);
145
+ (pub_key_mul_8, signature_r8, hasher.finish())
146
+ }
147
+
148
+ // Without the binding the forgery verifies, so the test below would fail if
149
+ // it ever accepted another decomposition.
150
+ #[test]
151
+ fn forgery_satisfies_the_equation_under_a_challenge_of_one() {
152
+ let (pub_key_mul_8, signature_r8, _) = forgery();
153
+ let base8: BabyJubJub = Curve { x: BASE8_X, y: BASE8_Y };
154
+
155
+ let left = base8.mul(ScalarField::<63>::from(0));
156
+ let right = signature_r8 + pub_key_mul_8.mul(ScalarField::<64>::from(1));
157
+ assert(left == right);
158
+ }
159
+
160
+ // The decomposition of one is what a hostile hint returns in place of the real
161
+ // challenge's; the binding has to reject it before the curve equation runs.
162
+ #[test(should_fail_with = "eddsa challenge decomposition does not match its hash")]
163
+ fn rejects_the_decomposition_of_another_challenge() {
164
+ let (pub_key_mul_8, signature_r8, hash) = forgery();
165
+ assert(hash != 1);
166
+
167
+ let _ = verify_with_challenge(
168
+ pub_key_mul_8,
169
+ signature_r8,
170
+ 0,
171
+ hash,
172
+ ScalarField::<64>::from(1),
173
+ );
174
+ }
175
+ }
package/src/main.nr CHANGED
@@ -1,4 +1,5 @@
1
1
  mod credential;
2
+ mod eddsa;
2
3
  mod issuer;
3
4
  mod types;
4
5