diff --git a/.github/workflows/coordinator-build.yml b/.github/workflows/coordinator-build.yml index 0221721b4..35d84c64a 100644 --- a/.github/workflows/coordinator-build.yml +++ b/.github/workflows/coordinator-build.yml @@ -5,6 +5,14 @@ on: branches: [dev] pull_request: +env: + COORDINATOR_PUBLIC_KEY_PATH: "./pub.key" + COORDINATOR_PRIVATE_KEY_PATH: "./priv.key" + COORDINATOR_TALLY_ZKEY_NAME: "TallyVotes" + COORDINATOR_MESSAGE_PROCESS_ZKEY_NAME: "ProcessMessages" + COORDINATOR_ZKEY_PATH: "./zkeys" + COORDINATOR_RAPIDSNARK_EXE: "" + concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true diff --git a/contracts/ts/index.ts b/contracts/ts/index.ts index b91dbd356..4f1b71052 100644 --- a/contracts/ts/index.ts +++ b/contracts/ts/index.ts @@ -19,8 +19,10 @@ export { formatProofForVerifierContract, getDefaultSigner, getDefaultNetwork, ge export { EMode } from "./constants"; export { Deployment } from "../tasks/helpers/Deployment"; export { ContractStorage } from "../tasks/helpers/ContractStorage"; +export { ProofGenerator } from "../tasks/helpers/ProofGenerator"; +export { Prover } from "../tasks/helpers/Prover"; export { EContracts } from "../tasks/helpers/types"; export { linkPoseidonLibraries } from "../tasks/helpers/abi"; -export type { IVerifyingKeyStruct, SnarkProof, Groth16Proof } from "./types"; +export type { IVerifyingKeyStruct, SnarkProof, Groth16Proof, Proof } from "./types"; export * from "../typechain-types"; diff --git a/coordinator/.env.example b/coordinator/.env.example index 089fa7830..4e281320b 100644 --- a/coordinator/.env.example +++ b/coordinator/.env.example @@ -1,2 +1,25 @@ +# Rate limit configuation TTL=60000 LIMIT=10 + +# Make sure your private and public RSA keys are generated (see package.json scripts) +# Public key must be copied to frontend app to encrypt user sensitive data +COORDINATOR_PUBLIC_KEY_PATH="./pub.key" +COORDINATOR_PRIVATE_KEY_PATH="./priv.key" + +# Make sure you have TallyVotesQv.zkey, TallyVotesQv.wasm +# and TallyVotesNonQv.zkey, TallyVotesNonQv.wasm inside COORDINATOR_ZKEY_PATH folder +# https://maci.pse.dev/docs/trusted-setup +COORDINATOR_TALLY_ZKEY_NAME=TallyVotes + +# Make sure you have ProcessMessagesQv.zkey, ProcessMessagesQv.wasm +# and ProcessMessagesNonQv.zkey, ProcessMessagesNonQv.wasm inside COORDINATOR_ZKEY_PATH folder +# https://maci.pse.dev/docs/trusted-setup +COORDINATOR_MESSAGE_PROCESS_ZKEY_NAME=ProcessMessages + +# Rapidsnark executable path +COORDINATOR_RAPIDSNARK_EXE= + +# Location of zkey, wasm files +COORDINATOR_ZKEY_PATH="./zkeys" + diff --git a/coordinator/.gitignore b/coordinator/.gitignore new file mode 100644 index 000000000..0e11495c4 --- /dev/null +++ b/coordinator/.gitignore @@ -0,0 +1,4 @@ +*.key +*.key +zkeys/ + diff --git a/coordinator/package.json b/coordinator/package.json index 4c0150b96..12946aa2c 100644 --- a/coordinator/package.json +++ b/coordinator/package.json @@ -9,13 +9,15 @@ "README.md" ], "scripts": { + "prebuild": "pnpm run generate-keypair", "build": "nest build", "start": "nest start", "start:dev": "nest start --watch", "start:prod": "node dist/main", "test": "jest", "test:coverage": "jest --coverage", - "types": "tsc -p tsconfig.json --noEmit" + "types": "tsc -p tsconfig.json --noEmit", + "generate-keypair": "ts-node ./scripts/generateKeypair.ts" }, "dependencies": { "@nestjs/common": "^10.3.8", @@ -42,9 +44,11 @@ "@types/jest": "^29.5.2", "@types/node": "^20.3.1", "@types/supertest": "^6.0.0", + "fast-check": "^3.18.0", "jest": "^29.5.0", "supertest": "^7.0.0", "ts-jest": "^29.1.2", + "ts-node": "^10.9.1", "typescript": "^5.4.5" }, "jest": { diff --git a/coordinator/scripts/generateKeypair.ts b/coordinator/scripts/generateKeypair.ts new file mode 100644 index 000000000..85a38dad0 --- /dev/null +++ b/coordinator/scripts/generateKeypair.ts @@ -0,0 +1,25 @@ +import dotenv from "dotenv"; + +import { generateKeyPairSync } from "crypto"; +import fs from "fs"; +import path from "path"; + +dotenv.config({ path: [path.resolve(__dirname, "../.env"), path.resolve(__dirname, "../.env.example")] }); + +const MODULUS_LENGTH = 4096; + +export async function generateRsaKeypair(): Promise { + const keypair = generateKeyPairSync("rsa", { + modulusLength: MODULUS_LENGTH, + }); + + const publicKey = keypair.publicKey.export({ type: "pkcs1", format: "pem" }); + const privateKey = keypair.privateKey.export({ type: "pkcs1", format: "pem" }); + + await Promise.all([ + fs.promises.writeFile(path.resolve(process.env.COORDINATOR_PUBLIC_KEY_PATH!), publicKey), + fs.promises.writeFile(path.resolve(process.env.COORDINATOR_PRIVATE_KEY_PATH!), privateKey), + ]); +} + +generateRsaKeypair(); diff --git a/coordinator/ts/crypto/__tests__/crypto.service.test.ts b/coordinator/ts/crypto/__tests__/crypto.service.test.ts new file mode 100644 index 000000000..2c1322b2d --- /dev/null +++ b/coordinator/ts/crypto/__tests__/crypto.service.test.ts @@ -0,0 +1,28 @@ +import fc from "fast-check"; + +import { generateKeyPairSync } from "crypto"; + +import { CryptoService } from "../crypto.service"; + +describe("CryptoService", () => { + test("should encrypt and decrypt properly", () => { + fc.assert( + fc.property(fc.string(), (text: string) => { + const service = CryptoService.getInstance(); + + const keypair = generateKeyPairSync("rsa", { + modulusLength: 2048, + }); + + const encryptedText = service.encrypt(keypair.publicKey.export({ type: "pkcs1", format: "pem" }), text); + + const decryptedText = service.decrypt( + keypair.privateKey.export({ type: "pkcs1", format: "pem" }), + encryptedText, + ); + + return decryptedText === text; + }), + ); + }); +}); diff --git a/coordinator/ts/crypto/crypto.service.ts b/coordinator/ts/crypto/crypto.service.ts new file mode 100644 index 000000000..c9c300a82 --- /dev/null +++ b/coordinator/ts/crypto/crypto.service.ts @@ -0,0 +1,57 @@ +import { publicEncrypt, privateDecrypt, type KeyLike } from "crypto"; + +/** + * CryptoService is responsible for encrypting and decrypting user sensitive data + */ +export class CryptoService { + /** + * Singleton instance + */ + private static INSTANCE?: CryptoService; + + /** + * Empty constructor + */ + private constructor() { + // use singleton initialization + } + + /** + * Get singleton crypto service instance + * + * @returns crypto service instance + */ + static getInstance(): CryptoService { + if (!CryptoService.INSTANCE) { + CryptoService.INSTANCE = new CryptoService(); + } + + return CryptoService.INSTANCE; + } + + /** + * Encrypt plaintext with public key + * + * @param publicKey - public key + * @param value - plaintext + * @returns ciphertext + */ + encrypt(publicKey: KeyLike, value: string): string { + const encrypted = publicEncrypt(publicKey, Buffer.from(value)); + + return encrypted.toString("base64"); + } + + /** + * Decrypt ciphertext with private key + * + * @param privateKey - private key + * @param value - ciphertext + * @returns plaintext + */ + decrypt(privateKey: KeyLike, value: string): string { + const decryptedData = privateDecrypt(privateKey, Buffer.from(value, "base64")); + + return decryptedData.toString(); + } +} diff --git a/coordinator/ts/main.ts b/coordinator/ts/main.ts index 3d366ecdd..2cb0c4705 100644 --- a/coordinator/ts/main.ts +++ b/coordinator/ts/main.ts @@ -2,7 +2,9 @@ import { NestFactory } from "@nestjs/core"; import dotenv from "dotenv"; import helmet from "helmet"; -dotenv.config(); +import path from "path"; + +dotenv.config({ path: [path.resolve(__dirname, "../.env"), path.resolve(__dirname, "../.env.example")] }); async function bootstrap() { const { AppModule } = await import("./app.module"); diff --git a/coordinator/ts/proof/__tests__/proof.service.test.ts b/coordinator/ts/proof/__tests__/proof.service.test.ts new file mode 100644 index 000000000..a94625a8b --- /dev/null +++ b/coordinator/ts/proof/__tests__/proof.service.test.ts @@ -0,0 +1,158 @@ +import dotenv from "dotenv"; +import { ZeroAddress } from "ethers"; +import { Deployment, ProofGenerator } from "maci-contracts"; + +import type { IGenerateArgs } from "../types"; + +import { CryptoService } from "../../crypto/crypto.service"; +import { ProofGeneratorService } from "../proof.service"; + +dotenv.config(); + +jest.mock("hardhat", (): unknown => ({ + network: { + name: "localhost", + config: { + chain: { id: 0x1 }, + }, + }, +})); + +jest.mock("maci-contracts", (): unknown => ({ + ...jest.requireActual("maci-contracts"), + Deployment: { + getInstance: jest.fn(), + }, + ProofGenerator: jest.fn(), +})); + +jest.mock("../../crypto/crypto.service", (): unknown => ({ + CryptoService: { + getInstance: jest.fn(), + }, +})); + +describe("ProofGeneratorService", () => { + const defaultArgs: IGenerateArgs = { + poll: 1n, + maciContractAddress: ZeroAddress, + tallyContractAddress: ZeroAddress, + useQuadraticVoting: false, + encryptedCoordinatorPrivateKey: + "siO9W/g7jNVXs9tOUv/pffrcqYdMlgdXw7nSSlqM1q1UvHGSSbhtLJpeT+nJKW7/+xrBTgI0wB866DSkg8Rgr8zD+POUMiKPrGqAO/XhrcmRDL+COURFNDRh9WGeAua6hdiNoufQYvXPl1iWyIYidSDbfmC2wR6F9vVkhg/6KDZyw8Wlr6LUh0RYT+hUHEwwGbz7MeqZJcJQSTpECPF5pnk8NTHL2W/XThaewB4n4HYqjDUbYLmBDLYWsDDMgoPo709a309rTq3uEe0YBgVF8g9aGxucTDhz+/LYYzqaeSxclUwen9Z4BGZjiDSPBZfooOEQEEwIJlViQ2kl1VeOKAmkiWEUVfItivmNbC/PNZchklmfFsGpiu4DT9UU9YVBN2OTcFYHHsslcaqrR7SuesqjluaGjG46oYEmfQlkZ4gXhavdWXw2ant+Tv6HRo4trqjoD1e3jUkN6gJMWomxOeRBTg0czBZlz/IwUtTpBHcKhi3EqGQo8OuQtWww+Ts7ySmeoONuovYUsIAppNuOubfUxvFJoTr2vKbWNAiYetw09kddkjmBe+S8A5PUiFOi262mfc7g5wJwPPP7wpTBY0Fya+2BCPzXqRLMOtNI+1tW3/UQLZYvEY8J0TxmhoAGZaRn8FKaosatRxDZTQS6QUNmKxpmUspkRKzTXN5lznM=", + }; + + let mockContract = { + polls: jest.fn(), + getMainRoot: jest.fn(), + treeDepths: jest.fn(), + extContracts: jest.fn(), + stateAqMerged: jest.fn(), + }; + + let defaultProofGenerator = { + generateMpProofs: jest.fn(), + generateTallyProofs: jest.fn(), + }; + + let defaultCryptoService = { + decrypt: jest.fn(), + }; + + const defaultDeploymentService = { + setHre: jest.fn(), + getDeployer: jest.fn(() => Promise.resolve({})), + getContract: jest.fn(() => Promise.resolve(mockContract)), + }; + + beforeEach(() => { + mockContract = { + polls: jest.fn(() => Promise.resolve()), + getMainRoot: jest.fn(() => Promise.resolve(1n)), + treeDepths: jest.fn(() => Promise.resolve([1, 2, 3])), + extContracts: jest.fn(() => Promise.resolve({ messageAq: ZeroAddress })), + stateAqMerged: jest.fn(() => Promise.resolve(true)), + }; + + defaultProofGenerator = { + generateMpProofs: jest.fn(() => Promise.resolve([1])), + generateTallyProofs: jest.fn(() => Promise.resolve([1])), + }; + + defaultCryptoService = { + decrypt: jest.fn(() => "macisk.6d5efa8ebc6f7a6ee3e9bf573346af2df29b007b29ef420c030aa4a7f3410182"), + }; + + (Deployment.getInstance as jest.Mock).mockReturnValue(defaultDeploymentService); + + (ProofGenerator as unknown as jest.Mock).mockReturnValue(defaultProofGenerator); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + ProofGenerator.prepareState = jest.fn(() => + Promise.resolve({ + polls: new Map([[1n, {}]]), + }), + ); + + (CryptoService.getInstance as jest.Mock).mockReturnValue(defaultCryptoService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test("should throw error if state is not merged yet", async () => { + mockContract.stateAqMerged.mockResolvedValue(false); + + const service = new ProofGeneratorService(); + + await expect(service.generate(defaultArgs)).rejects.toThrow( + "The state tree has not been merged yet. Please use the mergeSignups subcommmand to do so.", + ); + }); + + test("should throw error if there is no any poll", async () => { + mockContract.getMainRoot.mockResolvedValue(0n); + + const service = new ProofGeneratorService(); + + await expect(service.generate(defaultArgs)).rejects.toThrow( + "The message tree has not been merged yet. Please use the mergeMessages subcommmand to do so.", + ); + }); + + test("should throw error if poll is not found", async () => { + const service = new ProofGeneratorService(); + + await expect(service.generate({ ...defaultArgs, poll: 2n })).rejects.toThrow("Poll 2 not found"); + }); + + test("should throw error if coordinator key cannot be decrypted", async () => { + defaultCryptoService.decrypt.mockReturnValue("unknown"); + + const service = new ProofGeneratorService(); + + await expect(service.generate({ ...defaultArgs, encryptedCoordinatorPrivateKey: "unknown" })).rejects.toThrow( + "Cannot convert 0x to a BigInt", + ); + }); + + test("should generate proofs properly for NonQv", async () => { + const service = new ProofGeneratorService(); + + const data = await service.generate(defaultArgs); + + expect(data.processProofs).toHaveLength(1); + expect(data.tallyProofs).toHaveLength(1); + }); + + test("should generate proofs properly for Qv", async () => { + const service = new ProofGeneratorService(); + + const data = await service.generate({ ...defaultArgs, useQuadraticVoting: true }); + + expect(data.processProofs).toHaveLength(1); + expect(data.tallyProofs).toHaveLength(1); + }); +}); diff --git a/coordinator/ts/proof/proof.service.ts b/coordinator/ts/proof/proof.service.ts new file mode 100644 index 000000000..64aea3a1b --- /dev/null +++ b/coordinator/ts/proof/proof.service.ts @@ -0,0 +1,144 @@ +import { Injectable } from "@nestjs/common"; +import hre from "hardhat"; +import { Deployment, EContracts, ProofGenerator, type Poll, type MACI, type AccQueue } from "maci-contracts"; +import { Keypair, PrivKey } from "maci-domainobjs"; + +import fs from "fs"; +import path from "path"; + +import type { IGenerateArgs, IGenerateData } from "./types"; + +import { CryptoService } from "../crypto/crypto.service"; + +/** + * ProofGeneratorService is responsible for generating message processing and tally proofs. + */ +@Injectable() +export class ProofGeneratorService { + /** + * Deployment helper + */ + private deployment: Deployment; + + /** + * CryptoService for user sensitive data decryption + */ + private cryptoService: CryptoService; + + /** + * Proof generator initialization + */ + constructor() { + this.deployment = Deployment.getInstance(hre); + this.deployment.setHre(hre); + this.cryptoService = CryptoService.getInstance(); + } + + /** + * Generate proofs for message processing and tally + * + * @param args - generate proofs arguments + * @returns - generated proofs for message processing and tally + */ + async generate({ + poll, + maciContractAddress, + tallyContractAddress, + useQuadraticVoting, + encryptedCoordinatorPrivateKey, + startBlock, + endBlock, + blocksPerBatch, + }: IGenerateArgs): Promise { + const maciContract = await this.deployment.getContract({ + name: EContracts.MACI, + address: maciContractAddress, + }); + + const signer = await this.deployment.getDeployer(); + const pollAddress = await maciContract.polls(poll); + const pollContract = await this.deployment.getContract({ name: EContracts.Poll, address: pollAddress }); + const { messageAq: messageAqAddress } = await pollContract.extContracts(); + const messageAq = await this.deployment.getContract({ + name: EContracts.AccQueue, + address: messageAqAddress, + }); + + const isStateAqMerged = await pollContract.stateAqMerged(); + + if (!isStateAqMerged) { + throw new Error("The state tree has not been merged yet. Please use the mergeSignups subcommmand to do so."); + } + + const messageTreeDepth = await pollContract.treeDepths().then((depths) => Number(depths[2])); + + const mainRoot = await messageAq.getMainRoot(messageTreeDepth.toString()); + + if (mainRoot.toString() === "0") { + throw new Error("The message tree has not been merged yet. Please use the mergeMessages subcommmand to do so."); + } + + const privateKey = await fs.promises.readFile(path.resolve(process.env.COORDINATOR_PRIVATE_KEY_PATH!)); + const maciPrivateKey = PrivKey.deserialize(this.cryptoService.decrypt(privateKey, encryptedCoordinatorPrivateKey)); + const coordinatorKeypair = new Keypair(maciPrivateKey); + + const maciState = await ProofGenerator.prepareState({ + maciContract, + pollContract, + messageAq, + maciPrivateKey, + coordinatorKeypair, + pollId: poll, + signer, + options: { + startBlock, + endBlock, + blocksPerBatch, + }, + }); + + const foundPoll = maciState.polls.get(BigInt(poll)); + + if (!foundPoll) { + throw new Error(`Poll ${poll} not found`); + } + + const proofGenerator = new ProofGenerator({ + poll: foundPoll, + maciContractAddress, + tallyContractAddress, + tally: this.getZkeyFiles(process.env.COORDINATOR_TALLY_ZKEY_NAME!, useQuadraticVoting), + mp: this.getZkeyFiles(process.env.COORDINATOR_MESSAGE_PROCESS_ZKEY_NAME!, useQuadraticVoting), + rapidsnark: process.env.COORDINATOR_RAPIDSNARK_EXE, + outputDir: path.resolve("./proofs"), + tallyOutputFile: path.resolve("./tally"), + useQuadraticVoting, + }); + + const processProofs = await proofGenerator.generateMpProofs(); + const tallyProofs = await proofGenerator.generateTallyProofs(hre.network); + + return { + processProofs, + tallyProofs, + }; + } + + /** + * Get zkey, wasm and witgen filepaths for zkey set + * + * @param name - zkey set name + * @param useQuadraticVoting - whether to use Qv or NonQv + * @returns zkey and wasm filepaths + */ + private getZkeyFiles(name: string, useQuadraticVoting: boolean): { zkey: string; wasm: string; witgen: string } { + const root = path.resolve(process.env.COORDINATOR_ZKEY_PATH!); + const mode = useQuadraticVoting ? "Qv" : "NonQv"; + + return { + zkey: path.resolve(root, `${name}${mode}.zkey`), + wasm: path.resolve(root, `${name}${mode}.wasm`), + witgen: path.resolve(root, `${name}${mode}.witgen`), + }; + } +} diff --git a/coordinator/ts/proof/types.ts b/coordinator/ts/proof/types.ts new file mode 100644 index 000000000..93537fede --- /dev/null +++ b/coordinator/ts/proof/types.ts @@ -0,0 +1,77 @@ +import type { BigNumberish } from "ethers"; +import type { Proof } from "maci-contracts"; + +/** + * Interface that represents generate proofs arguments + */ +export interface IGenerateArgs { + /** + * Poll id + */ + poll: BigNumberish; + + /** + * Maci contract address + */ + maciContractAddress: string; + + /** + * Tally contract address + */ + tallyContractAddress: string; + + /** + * Whether to use Qv or NonQv + */ + useQuadraticVoting: boolean; + + /** + * Encrypted coordinator private key with RSA public key (see .env.example) + */ + encryptedCoordinatorPrivateKey: string; + + /** + * Start block for event processing + */ + startBlock?: number; + + /** + * End block for event processing + */ + endBlock?: number; + + /** + * Blocks per batch for event processing + */ + blocksPerBatch?: number; +} + +/** + * Interface that represents generated proofs data + */ +export interface IGenerateData { + /** + * Message processing proofs + */ + processProofs: Proof[]; + + /** + * Tally proofs + */ + tallyProofs: Proof[]; +} + +/** + * Interface that represents zkey filepaths + */ +export interface IGetZkeyFilesData { + /** + * Zkey filepath + */ + zkey: string; + + /** + * Wasm filepath + */ + wasm: string; +} diff --git a/coordinator/tsconfig.build.json b/coordinator/tsconfig.build.json index e6fc722d1..260b7ae4b 100644 --- a/coordinator/tsconfig.build.json +++ b/coordinator/tsconfig.build.json @@ -5,6 +5,6 @@ "emitDecoratorMetadata": true, "experimentalDecorators": true }, - "include": ["./ts"], + "include": ["./ts", "./scripts"], "files": ["./hardhat.config.ts"] } diff --git a/coordinator/tsconfig.json b/coordinator/tsconfig.json index bf80101c9..ad2e1af91 100644 --- a/coordinator/tsconfig.json +++ b/coordinator/tsconfig.json @@ -5,6 +5,6 @@ "emitDecoratorMetadata": true, "experimentalDecorators": true }, - "include": ["./ts", "./tests"], + "include": ["./ts", "./scripts", "./tests"], "files": ["./hardhat.config.ts"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ead7c22c..ebd5ee487 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -369,6 +369,9 @@ importers: '@types/supertest': specifier: ^6.0.0 version: 6.0.2 + fast-check: + specifier: ^3.18.0 + version: 3.18.0 jest: specifier: ^29.5.0 version: 29.7.0(@types/node@20.12.7)(ts-node@10.9.2) @@ -378,6 +381,9 @@ importers: ts-jest: specifier: ^29.1.2 version: 29.1.2(@babel/core@7.24.4)(jest@29.7.0)(typescript@5.4.5) + ts-node: + specifier: ^10.9.1 + version: 10.9.2(@types/node@20.12.7)(typescript@5.4.5) typescript: specifier: ^5.4.5 version: 5.4.5 @@ -10162,6 +10168,13 @@ packages: engines: {node: '> 0.1.90'} dev: false + /fast-check@3.18.0: + resolution: {integrity: sha512-/951xaT0kA40w0GXRsZXEwSTE7LugjZtSA/8vPgFkiPQ8wNp8tRvqWuNDHBgLxJYXtsK11e/7Q4ObkKW5BdTFQ==} + engines: {node: '>=8.0.0'} + dependencies: + pure-rand: 6.1.0 + dev: true + /fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}