Skip to content

Commit 6ad5405

Browse files
Merge pull request o1-labs#1815 from o1-labs/shigoto-ecdsa-ethers
Enhance Ethereum Signature Verification Add Curve Parsing Methods Split vKey regression CI tests into two calls
2 parents 81dd73c + 10c2e56 commit 6ad5405

File tree

7 files changed

+279
-18
lines changed

7 files changed

+279
-18
lines changed

run-ci-tests.sh

+2-1
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ case $TEST_TYPE in
3535

3636
"Verification Key Regression Check")
3737
echo "Running Regression checks"
38-
./run ./tests/vk-regression/vk-regression.ts --bundle
38+
VK_TEST=1 ./run ./tests/vk-regression/vk-regression.ts --bundle
39+
VK_TEST=2 ./run ./tests/vk-regression/vk-regression.ts --bundle
3940
;;
4041

4142
"CommonJS test")

src/examples/crypto/ecdsa/ecdsa.ts

+18-3
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
import {
22
ZkProgram,
33
Crypto,
4-
createEcdsa,
4+
createEcdsaV2,
55
createForeignCurveV2,
66
Bool,
77
Bytes,
88
} from 'o1js';
99

10-
export { keccakAndEcdsa, ecdsa, Secp256k1, Ecdsa, Bytes32 };
10+
export { keccakAndEcdsa, ecdsa, Secp256k1, Ecdsa, Bytes32, ecdsaEthers };
1111

1212
class Secp256k1 extends createForeignCurveV2(Crypto.CurveParams.Secp256k1) {}
1313
class Scalar extends Secp256k1.Scalar {}
14-
class Ecdsa extends createEcdsa(Secp256k1) {}
14+
class Ecdsa extends createEcdsaV2(Secp256k1) {}
1515
class Bytes32 extends Bytes(32) {}
1616

1717
const keccakAndEcdsa = ZkProgram({
@@ -43,3 +43,18 @@ const ecdsa = ZkProgram({
4343
},
4444
},
4545
});
46+
47+
const ecdsaEthers = ZkProgram({
48+
name: 'ecdsa-ethers',
49+
publicInput: Bytes32,
50+
publicOutput: Bool,
51+
52+
methods: {
53+
verifyEthers: {
54+
privateInputs: [Ecdsa, Secp256k1],
55+
async method(message: Bytes32, signature: Ecdsa, publicKey: Secp256k1) {
56+
return signature.verifyEthers(message, publicKey);
57+
},
58+
},
59+
},
60+
});

src/examples/crypto/ecdsa/run.ts

+47-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import { Secp256k1, Ecdsa, keccakAndEcdsa, ecdsa, Bytes32 } from './ecdsa.js';
1+
import {
2+
Secp256k1,
3+
Ecdsa,
4+
keccakAndEcdsa,
5+
ecdsa,
6+
ecdsaEthers,
7+
Bytes32,
8+
} from './ecdsa.js';
29
import assert from 'assert';
310

411
// create an example ecdsa signature
@@ -34,3 +41,42 @@ console.timeEnd('keccak + ecdsa verify (prove)');
3441

3542
proof.publicOutput.assertTrue('signature verifies');
3643
assert(await keccakAndEcdsa.verify(proof), 'proof verifies');
44+
45+
// Hardcoded ethers.js signature and inputs for verification in o1js
46+
47+
// message signed using ethers.js
48+
const msg = 'Secrets hidden, truth in ZKPs ;)';
49+
50+
// uncompressed public key generated by ethers.js
51+
const uncompressedPublicKey =
52+
'0x040957928494c38660d254dc03ba78f091a4aea0270afb447f193c4daf6648f02b720071af9b5bda4936998ec186e632f4be82886914851d7c753747b0a949d1a4';
53+
54+
// compressed public key generated by ethers.js
55+
const compressedPublicKey =
56+
'0x020957928494c38660d254dc03ba78f091a4aea0270afb447f193c4daf6648f02b';
57+
58+
// ECDSA signature generated by ethers.js
59+
const rawSignature =
60+
'0x6fada464c3bc2ae127f8c907c0c4bccbd05ba83a584156edb808b7400346b4c9558598d9c7869f5fd75d81128711f6621e4cb5ba2f52a2a51c46c859f49a833a1b';
61+
62+
const publicKeyE = Secp256k1.fromEthers(compressedPublicKey);
63+
const signatureE = Ecdsa.fromHex(rawSignature);
64+
const msgBytes = Bytes32.fromString(msg);
65+
66+
// investigate the constraint system generated by ECDSA verifyEthers
67+
console.time('ethers verify only (build constraint system)');
68+
let csEcdsaEthers = await ecdsaEthers.analyzeMethods();
69+
console.timeEnd('ethers verify only (build constraint system)');
70+
console.log(csEcdsaEthers.verifyEthers.summary());
71+
72+
// compile and prove
73+
console.time('ecdsa / ethers verify (compile)');
74+
await ecdsaEthers.compile();
75+
console.timeEnd('ecdsa / ethers verify (compile)');
76+
77+
console.time('ecdsa / ethers verify (prove)');
78+
let proofE = await ecdsaEthers.verifyEthers(msgBytes, signatureE, publicKeyE);
79+
console.timeEnd('ecdsa / ethers verify (prove)');
80+
81+
proofE.publicOutput.assertTrue('signature verifies');
82+
assert(await ecdsaEthers.verify(proofE), 'proof verifies');

src/lib/provable/crypto/foreign-curve.ts

+120-1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { assert } from '../gadgets/common.js';
1212
import { Provable } from '../provable.js';
1313
import { provableFromClass } from '../types/provable-derivers.js';
1414
import { l2Mask, multiRangeCheck } from '../gadgets/range-check.js';
15+
import { Bytes } from '../bytes.js';
1516

1617
// external API
1718
export {
@@ -43,7 +44,7 @@ class ForeignCurve {
4344
* Create a new {@link ForeignCurve} from an object representing the (affine) x and y coordinates.
4445
*
4546
* Note: Inputs must be range checked if they originate from a different field with a different modulus or if they are not constants. Please refer to the {@link ForeignField} constructor comments for more details.
46-
*
47+
*
4748
* @example
4849
* ```ts
4950
* let x = new ForeignCurve({ x: 1n, y: 1n });
@@ -74,6 +75,124 @@ class ForeignCurve {
7475
return new this(g);
7576
}
7677

78+
/**
79+
* Parses a hexadecimal string representing an uncompressed elliptic curve point and coerces it into a {@link ForeignCurveV2} point.
80+
*
81+
* The method extracts the x and y coordinates from the provided hex string and verifies that the resulting point lies on the curve.
82+
*
83+
* **Note:** This method only supports uncompressed elliptic curve points, which are 65 bytes in total (1-byte prefix + 32 bytes for x + 32 bytes for y).
84+
*
85+
* @param hex - The hexadecimal string representing the uncompressed elliptic curve point.
86+
* @returns - A point on the foreign curve, parsed from the given hexadecimal string.
87+
*
88+
* @throws - Throws an error if the input is not a valid public key.
89+
*
90+
* @example
91+
* ```ts
92+
* class Secp256k1 extends createForeignCurveV2(Crypto.CurveParams.Secp256k1) {}
93+
*
94+
* const publicKeyHex = '04f8b8db25c619d0c66b2dc9e97ecbafafae...'; // Example hex string for uncompressed point
95+
* const point = Secp256k1.fromHex(publicKeyHex);
96+
* ```
97+
*
98+
* **Important:** This method is only designed to handle uncompressed elliptic curve points in hex format.
99+
*/
100+
static fromHex(hex: string) {
101+
// trim the '0x' prefix if present
102+
if (hex.startsWith('0x')) {
103+
hex = hex.slice(2);
104+
}
105+
106+
const bytes = Bytes.fromHex(hex).toBytes();
107+
const sizeInBytes = Math.ceil(this.Bigint.Field.sizeInBits / 8);
108+
109+
// extract x and y coordinates from the byte array
110+
const tail = bytes.subarray(1); // skip the first byte (prefix)
111+
const xBytes = tail.subarray(0, sizeInBytes); // first `sizeInBytes` bytes for x-coordinate
112+
const yBytes = tail.subarray(sizeInBytes, 2 * sizeInBytes); // next `sizeInBytes` bytes for y-coordinate
113+
114+
// convert byte arrays to bigint
115+
const x = BigInt('0x' + Bytes.from(xBytes).toHex());
116+
const y = BigInt('0x' + Bytes.from(yBytes).toHex());
117+
118+
// construct the point on the curve using the x and y coordinates
119+
let P = this.from({ x, y });
120+
121+
// ensure that the point is on the curve
122+
P.assertOnCurve();
123+
124+
return P;
125+
}
126+
127+
/**
128+
* Create a new {@link ForeignCurveV2} instance from an Ethereum public key in hex format, which may be either compressed or uncompressed.
129+
* This method is designed to handle the parsing of public keys as used by the ethers.js library.
130+
*
131+
* The input should represent the affine x and y coordinates of the point, in hexadecimal format.
132+
* Compressed keys are 33 bytes long and begin with 0x02 or 0x03, while uncompressed keys are 65 bytes long and begin with 0x04.
133+
*
134+
* **Warning:** This method is specifically designed for use with the Secp256k1 curve. Using it with other curves may result in incorrect behavior or errors.
135+
* Ensure that the curve setup matches Secp256k1, as shown in the example, to avoid unintended issues.
136+
*
137+
* @example
138+
* ```ts
139+
* import { Wallet, Signature, getBytes } from 'ethers';
140+
*
141+
* class Secp256k1 extends createForeignCurveV2(Crypto.CurveParams.Secp256k1) {}
142+
*
143+
* const wallet = Wallet.createRandom();
144+
*
145+
* const publicKey = Secp256k1.fromEthers(wallet.publicKey.slice(2));
146+
* ```
147+
*
148+
* @param hex - The public key as a hexadecimal string (without the "0x" prefix).
149+
* @returns A new instance of the curve representing the given public key.
150+
*/
151+
static fromEthers(hex: string) {
152+
// trim the '0x' prefix if present
153+
if (hex.startsWith('0x')) {
154+
hex = hex.slice(2);
155+
}
156+
157+
const bytes = Bytes.fromHex(hex).toBytes(); // convert hex string to Uint8Array
158+
const len = bytes.length;
159+
const head = bytes[0]; // first byte is the prefix (compression identifier)
160+
const tail = bytes.slice(1); // remaining bytes contain the coordinates
161+
162+
const xBytes = tail.slice(0, 32); // extract the x-coordinate (first 32 bytes)
163+
const x = BigInt('0x' + Bytes.from(xBytes).toHex()); // convert Uint8Array to bigint
164+
165+
let p: { x: bigint; y: bigint } | undefined = undefined;
166+
167+
// handle compressed points (33 bytes, prefix 0x02 or 0x03)
168+
if (len === 33 && [0x02, 0x03].includes(head)) {
169+
// ensure x is within the valid field range
170+
assert(0n < x && x < this.Bigint.Field.modulus);
171+
172+
// compute the right-hand side of the curve equation: x³ + ax + b
173+
const crvX = this.Bigint.Field.mod(
174+
this.Bigint.Field.mod(x * x) * x + this.Bigint.b
175+
);
176+
// compute the square root (y-coordinate)
177+
let y = this.Bigint.Field.sqrt(crvX)!;
178+
const isYOdd = (y & 1n) === 1n; // determine whether y is odd
179+
const headOdd = (head & 1) === 1; // determine whether the prefix indicates an odd y
180+
if (headOdd !== isYOdd) y = this.Bigint.Field.mod(-y); // adjust y if necessary
181+
p = { x, y };
182+
}
183+
184+
// handle uncompressed points (65 bytes, prefix 0x04)
185+
if (len === 65 && head === 0x04) {
186+
const yBytes = tail.slice(32, 64); // extract the y-coordinate (next 32 bytes)
187+
p = { x, y: BigInt('0x' + Bytes.from(yBytes).toHex()) };
188+
}
189+
190+
const P = this.from(p!); // create the curve point from the parsed coordinates
191+
P.assertOnCurve(); // verify the point lies on the curve
192+
193+
return P;
194+
}
195+
77196
/**
78197
* The constant generator point.
79198
*/

src/lib/provable/crypto/foreign-ecdsa.ts

+53-1
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ class EcdsaSignature {
3838

3939
/**
4040
* Create a new {@link EcdsaSignature} from an object containing the scalars r and s.
41-
*
41+
*
4242
* Note: Inputs must be range checked if they originate from a different field with a different modulus or if they are not constants. Please refer to the {@link ForeignField} constructor comments for more details.
4343
*/
4444
constructor(signature: {
@@ -122,6 +122,58 @@ class EcdsaSignature {
122122
return this.verifySignedHashV2(msgHash, publicKey);
123123
}
124124

125+
/**
126+
* Verify an ECDSA signature generated by the ethers.js library, given the message (as a byte array) and a public key (a {@link Curve} point).
127+
* The message digest used for signing follows the format defined in EIP-191, with the Ethereum-specific prefix.
128+
*
129+
* **Important:** This method returns a {@link Bool} which indicates whether the signature is valid.
130+
* So, to actually prove validity of a signature, you need to assert that the result is true.
131+
*
132+
* **Note:** This method is specifically designed to verify signatures generated by ethers.js.
133+
* Ensure that the curve being used is Secp256k1, as demonstrated in the example.
134+
*
135+
* @throws An error will be thrown if one of the signature scalars is zero or if the public key does not lie on the curve.
136+
*
137+
* @example
138+
* ```ts
139+
* import { Wallet } from 'ethers';
140+
*
141+
* // create the class for Secp256k1 curve
142+
* class Secp256k1 extends createForeignCurveV2(Crypto.CurveParams.Secp256k1) {}
143+
* class Ecdsa extends createEcdsaV2(Secp256k1) {}
144+
*
145+
* // outside provable code: create inputs
146+
* let message = 'my message';
147+
* let signatureRaw = await wallet.signMessage(message);
148+
* let compressedPublicKey = wallet.signingKey.compressedPublicKey;
149+
*
150+
* // this also works for uncompressed public keys (wallet.signingKey.publicKey)
151+
* let publicKey = Secp256k1.fromEthers(compressedPublicKey.slice(2));
152+
* let signature = Ecdsa.fromHex(signatureRaw);
153+
*
154+
* // ...
155+
* // in provable code: create input witnesses (or use method inputs, or constants)
156+
* // and verify the signature
157+
* let isValid = signature.verifyEthers(Bytes.fromString(message), publicKey);
158+
* isValid.assertTrue('signature verifies');
159+
* ```
160+
*
161+
* @param message - The original message as a byte array.
162+
* @param publicKey - The public key as a point on the Secp256k1 elliptic curve.
163+
* @returns - A {@link Bool} indicating the validity of the signature.
164+
*/
165+
verifyEthers(message: Bytes, publicKey: FlexiblePoint): Bool {
166+
const MessagePrefix = '\x19Ethereum Signed Message:\n'; // Ethereum-specific prefix for signing
167+
const msgHashBytes = Keccak.ethereum([
168+
...Bytes.fromString(MessagePrefix).bytes, // prefix for Ethereum signed messages
169+
...Bytes.fromString(String(message.length)).bytes, // message length as string
170+
...message.bytes, // actual message bytes
171+
]);
172+
173+
let msgHash = keccakOutputToScalar(msgHashBytes, this.Constructor.Curve);
174+
return this.verifySignedHashV2(msgHash, publicKey);
175+
}
176+
125177
/**
126178
* @deprecated There is a security vulnerability in this method. Use {@link verifySignedHashV2} instead.
127179
*/

0 commit comments

Comments
 (0)