UNPKG

mina-attestations

Version:
327 lines (282 loc) 9.67 kB
import { Bytes, createEcdsa, createForeignCurve, Crypto, EcdsaSignature, Experimental, Field, type From, Keccak, Provable, UInt8, Unconstrained, ZkProgram, } from 'o1js'; import { Credential } from '../credential-index.ts'; import { DynamicArray, DynamicBytes, DynamicSHA3 } from '../dynamic.ts'; import { bytesToBigintBE } from '../rsa/utils.ts'; import { assert, ByteUtils } from '../util.ts'; import { unpackBytes } from '../dynamic/gadgets.ts'; export { EcdsaEthereum, parseSignature, parseAddress, recoverPublicKey, publicKeyToAddress, verifyEthereumSignature, verifyEthereumSignatureSimple, getHashHelper, }; class PublicKey extends createForeignCurve(Crypto.CurveParams.Secp256k1) {} class Signature extends createEcdsa(PublicKey) {} const MessageHash = Bytes(32); const Address = Bytes(20); /** * Ethereum-style ECDSA signature credentials. */ const EcdsaEthereum = { Signature, PublicKey, MessageHash: MessageHash as typeof Bytes.Base, Address: Address as typeof Bytes.Base, parseSignature, parseAddress, /** * Credential that wraps an Ethereum-style ECDSA signature. */ Credential({ maxMessageLength }: { maxMessageLength: number }) { let cred = ecdsaCredentials.get(maxMessageLength); cred ??= createCredential({ maxMessageLength }); ecdsaCredentials.set(maxMessageLength, cred); return cred; }, compileDependencies, }; const ecdsaCredentials = new Map<number, ReturnType<typeof createCredential>>(); const hashHelpers = new Map<number, ReturnType<typeof createHashHelper>>(); function createCredential(options: { maxMessageLength: number }) { let { maxMessageLength } = options; const Message = DynamicBytes({ maxLength: maxMessageLength }); return Credential.Imported.fromMethod( { name: `ecdsa-${maxMessageLength}`, publicInput: { signerAddress: Address }, privateInput: { message: Message, signature: Signature, parityBit: Unconstrained.withEmpty(false), }, data: { message: Message }, }, async ({ publicInput: { signerAddress }, privateInput: { message, signature, parityBit }, }) => { await verifyEthereumSignature( message, signature, signerAddress, parityBit, maxMessageLength ); return { message }; } ); } /** * Compile the ECDSA credential for the given message length and its ZkProgram dependencies. */ async function compileDependencies({ maxMessageLength, proofsEnabled = true, }: { maxMessageLength: number; proofsEnabled?: boolean; }) { await getHashHelper(maxMessageLength).compile({ proofsEnabled }); let cred = await EcdsaEthereum.Credential({ maxMessageLength }); await cred.compile({ proofsEnabled }); } // Ethereum signed message hash (EIP-191), assuming a 32-byte message that resulted from another hash const MESSAGE_PREFIX = '\x19Ethereum Signed Message:\n32'; /** * Recursive ethereum-style signature verification. * * - The message is hashed in two steps, first a normal message hash (using Keccak) and then final EIP-191 hash. * - The public key is recovered from the message, signature and parity bit. It is shown to be correct by hashing to the signer address. * - The method returns nothing, and fails if the signature is invalid. */ async function verifyEthereumSignature( message: DynamicArray<UInt8>, signature: Signature, signerAddress: Bytes, parityBit: Unconstrained<boolean>, maxMessageLength: number ) { // in a recursive call, hash the message const hashHelper = getHashHelper(maxMessageLength); const hashHelperRecursive = Experimental.Recursive(hashHelper); let messageHash = await hashHelperRecursive.short(message); // witness the recovered public key let publicKey = Provable.witness(PublicKey, () => recoverPublicKey(messageHash, signature, parityBit.get()) ); // check that public key hashes to address let recoveredAddress = publicKeyToAddress(publicKey); Provable.assertEqual(Address, recoveredAddress, signerAddress); // verify the signature against the now-validated public key let ok = signature.verifySignedHash(messageHash, publicKey); ok.assertTrue('signature is invalid'); return { message }; } /** * Non-recursive e2e signature verification, for benchmarking and testing and using outside circuits. * * Note: this is not provable due to the circuit limit, use the recursive version for that. */ function verifyEthereumSignatureSimple( message: DynamicArray<UInt8>, signature: Signature, signerAddress: Bytes, parityBit: Unconstrained<boolean> ) { // hash the message let messageHash = DynamicSHA3.keccak256(message); let finalHash = Keccak.ethereum([ ...Bytes.fromString(MESSAGE_PREFIX).bytes, ...messageHash.bytes, ]); // witness the recovered public key let publicKey = Provable.witness(PublicKey, () => recoverPublicKey(finalHash, signature, parityBit.get()) ); // check that public key hashes to address let recoveredAddress = publicKeyToAddress(publicKey); Provable.assertEqual(Address, recoveredAddress, signerAddress); // verify the signature against the now-validated public key let ok = signature.verifySignedHash(finalHash, publicKey); ok.assertTrue('signature is invalid'); } // helper functions function publicKeyToAddress(pk: From<typeof PublicKey>) { let { x, y } = PublicKey.from(pk); // convert both x and y to 32-byte big-endian integers let xBytes = foreignFieldToBytes32BE(x); let yBytes = foreignFieldToBytes32BE(y); // hash the concatenation of x and y // and take the last 20 bytes as the address let pkHash = Keccak.ethereum([...xBytes, ...yBytes]); return Address.from(pkHash.bytes.slice(-20)); } /** * Convert 3x88-bit foreign field to 32 big-endian bytes. * Asserts that the 33th byte is zero. */ function foreignFieldToBytes32BE(field: { value: [Field, Field, Field] }) { let [x0, x1, x2] = field.value; let bytes0 = unpackBytes(x0, 11); let bytes1 = unpackBytes(x1, 11); let bytes2 = unpackBytes(x2, 11); let extraByte = bytes2.pop()!; extraByte.assertEquals(0, 'Foreign field exceeds 32 bytes'); // since big-endian is expected, we reverse the bytes return [...bytes0, ...bytes1, ...bytes2].reverse(); } /** * Recover the implied public key from a message hash, signature and a parity bit. */ function recoverPublicKey( messageHash: Bytes | Uint8Array, signature: From<typeof EcdsaSignature>, isOdd: boolean ) { let { Scalar: { Bigint: Scalar }, Field: { Bigint: Field }, Bigint: Curve, } = EcdsaEthereum.PublicKey; // read inputs if (messageHash instanceof Bytes.Base) messageHash = messageHash.toBytes(); if (!(signature instanceof EcdsaEthereum.Signature)) signature = EcdsaEthereum.Signature.from(signature); let m = Scalar.mod(bytesToBigintBE(messageHash)); let r = signature.r.toBigInt(); let s = signature.s.toBigInt(); // first, recover R_y from R_x and parity let x = Field.mod(r); let x3 = Field.mul(x, Field.square(x)); let y2 = Field.add(x3, Field.mul(Curve.a, x) + Curve.b); let y = Field.sqrt(y2); assert(y !== undefined); if (Field.isEven(y) !== !isOdd) y = Field.negate(y); let R = { x, y, infinity: false }; // recover public key let rInv = Scalar.inverse(r); assert(rInv !== undefined); let publicKey = Curve.sub( Curve.scale(R, Scalar.mul(s, rInv)), Curve.scale(Curve.one, Scalar.mul(m, rInv)) ); assert(publicKey.infinity === false); return publicKey; } /** * Input can be a hex string or a Uint8Array. */ function parseSignature(signature: string | Uint8Array) { if (typeof signature === 'string') signature = ByteUtils.fromHex(signature); assert(signature.length === 65); let r = bytesToBigintBE(signature.slice(0, 32)); let s = bytesToBigintBE(signature.slice(32, 64)); let v = signature[64]!; assert(v === 27 || v === 28, `Invalid recovery id "v" ${v}`); // Convert v to parity of R_y (27/28 -> 0/1 -> boolean) let parityBit = !!(v - 27); return { signature: { r, s }, parityBit }; } /** * Helper function to convert a hex address to the Bytes expected by the ECDSA credential. */ function parseAddress(address: string): Bytes { return Address.from(ByteUtils.fromHex(address)); } /* RECURSIVE PROOF SPLITTING there are two versions, an easy one that only supports messages up to a few keccak blocks, and a flexible and more complex one. VERSION 1: (main) ---- (hash message hash and message) VERSION 2, with two recursive branches: (main) ---- (hash message hash and message) \--- (hash message hash and end of message) --> (recursive message hash) */ function getHashHelper(maxMessageLength: number) { if (!hashHelpers.has(maxMessageLength)) { hashHelpers.set(maxMessageLength, createHashHelper(maxMessageLength)); } return hashHelpers.get(maxMessageLength)!; } function createHashHelper(maxMessageLength: number) { const Message = DynamicArray(UInt8, { maxLength: maxMessageLength }); let hashHelperProgram = ZkProgram({ name: 'ecdsa-hash-helper', publicInput: Message, publicOutput: MessageHash, methods: { short: { privateInputs: [], async method(message: DynamicBytes) { let intermediateHash = DynamicSHA3.keccak256(message); let messageHash = Keccak.ethereum([ ...Bytes.fromString(MESSAGE_PREFIX).bytes, ...intermediateHash.bytes, ]); return { publicOutput: messageHash }; }, }, // TODO: implement version 2 / "long" method }, }); return hashHelperProgram; }