diff --git a/circuits/circom/messageValidator.circom b/circuits/circom/messageValidator.circom index fae47b6a87..45fa31f3f9 100644 --- a/circuits/circom/messageValidator.circom +++ b/circuits/circom/messageValidator.circom @@ -20,6 +20,7 @@ template MessageValidator() { validStateLeafIndex.in[0] <== stateTreeIndex; validStateLeafIndex.in[1] <== numSignUps; + // @todo check if we need this if we do the check inside processOne // b) Whether the max vote option tree index is correct signal input voteOptionIndex; signal input maxVoteOptions; diff --git a/circuits/circom/processMessages.circom b/circuits/circom/processMessages.circom index d1c2f2db2c..f2db72eca6 100644 --- a/circuits/circom/processMessages.circom +++ b/circuits/circom/processMessages.circom @@ -613,8 +613,16 @@ template ProcessOne(stateTreeDepth, voteOptionTreeDepth) { isMessageValid.in[0] <== bothValid; isMessageValid.in[1] <== transformer.isValid + enoughVoiceCredits.out; + // check that the vote option index is < maxVoteOptions (0-indexed) + component validVoteOptionIndex = SafeLessThan(N_BITS); + validVoteOptionIndex.in[0] <== cmdVoteOptionIndex; + validVoteOptionIndex.in[1] <== maxVoteOptions; + + // @note pick the correct vote option index based on whether the index is < max vote options + // @todo can probably add one output to messageValidator and take from there + // or maybe we can remove altogther from messageValidator so we don't double check this component cmdVoteOptionIndexMux = Mux1(); - cmdVoteOptionIndexMux.s <== isMessageValid.out; + cmdVoteOptionIndexMux.s <== validVoteOptionIndex.out; cmdVoteOptionIndexMux.c[0] <== 0; cmdVoteOptionIndexMux.c[1] <== cmdVoteOptionIndex; diff --git a/circuits/circom/processMessagesNonQv.circom b/circuits/circom/processMessagesNonQv.circom index 25fe5e36b9..56d44d8e28 100644 --- a/circuits/circom/processMessagesNonQv.circom +++ b/circuits/circom/processMessagesNonQv.circom @@ -535,8 +535,16 @@ template ProcessOneNonQv(stateTreeDepth, voteOptionTreeDepth) { isMessageValid.in[0] <== bothValid; isMessageValid.in[1] <== transformer.isValid + enoughVoiceCredits.out; + // check that the vote option index is < maxVoteOptions (0-indexed) + component validVoteOptionIndex = SafeLessThan(N_BITS); + validVoteOptionIndex.in[0] <== cmdVoteOptionIndex; + validVoteOptionIndex.in[1] <== maxVoteOptions; + + // @note pick the correct vote option index based on whether the index is < max vote options + // @todo can probably add one output to messageValidator and take from there + // or maybe we can remove altogther from messageValidator so we don't double check this component cmdVoteOptionIndexMux = Mux1(); - cmdVoteOptionIndexMux.s <== isMessageValid.out; + cmdVoteOptionIndexMux.s <== validVoteOptionIndex.out; cmdVoteOptionIndexMux.c[0] <== 0; cmdVoteOptionIndexMux.c[1] <== cmdVoteOptionIndex; diff --git a/circuits/ts/__tests__/ProcessMessages.test.ts b/circuits/ts/__tests__/ProcessMessages.test.ts index f5ed57ce5d..05f3d2f276 100644 --- a/circuits/ts/__tests__/ProcessMessages.test.ts +++ b/circuits/ts/__tests__/ProcessMessages.test.ts @@ -674,4 +674,390 @@ describe("ProcessMessage circuit", function test() { await circuit.expectConstraintPass(witness); }); }); + + describe("1 user, 2 messages", () => { + const maciState = new MaciState(STATE_TREE_DEPTH); + const voteOptionIndex = 1n; + let stateIndex: bigint; + let pollId: bigint; + let poll: Poll; + const messages: Message[] = []; + const commands: PCommand[] = []; + + before(() => { + // Sign up and publish + const userKeypair = new Keypair(new PrivKey(BigInt(1))); + stateIndex = BigInt( + maciState.signUp(userKeypair.pubKey, voiceCreditBalance, BigInt(Math.floor(Date.now() / 1000))), + ); + + pollId = maciState.deployPoll( + BigInt(Math.floor(Date.now() / 1000) + duration), + maxValues, + treeDepths, + messageBatchSize, + coordinatorKeypair, + ); + + poll = maciState.polls.get(pollId)!; + poll.updatePoll(BigInt(maciState.stateLeaves.length)); + + const nothing = new Message(1n, [ + 8370432830353022751713833565135785980866757267633941821328460903436894336785n, + 0n, + 0n, + 0n, + 0n, + 0n, + 0n, + 0n, + 0n, + 0n, + ]); + + const encP = new PubKey([ + 10457101036533406547632367118273992217979173478358440826365724437999023779287n, + 19824078218392094440610104313265183977899662750282163392862422243483260492317n, + ]); + + poll.publishMessage(nothing, encP); + + // First command (valid) + const command = new PCommand( + stateIndex, // BigInt(1), + userKeypair.pubKey, + 1n, // voteOptionIndex, + 2n, // vote weight + 2n, // nonce + pollId, + ); + + const signature = command.sign(userKeypair.privKey); + + const ecdhKeypair = new Keypair(); + const sharedKey = Keypair.genEcdhSharedKey(ecdhKeypair.privKey, coordinatorKeypair.pubKey); + const message = command.encrypt(signature, sharedKey); + messages.push(message); + commands.push(command); + + poll.publishMessage(message, ecdhKeypair.pubKey); + + // Second command (valid) + const command2 = new PCommand( + stateIndex, + userKeypair.pubKey, + voteOptionIndex, // voteOptionIndex, + 9n, // vote weight 9 ** 2 = 81 + 1n, // nonce + pollId, + ); + const signature2 = command2.sign(userKeypair.privKey); + + const ecdhKeypair2 = new Keypair(); + const sharedKey2 = Keypair.genEcdhSharedKey(ecdhKeypair2.privKey, coordinatorKeypair.pubKey); + const message2 = command2.encrypt(signature2, sharedKey2); + messages.push(message2); + commands.push(command2); + poll.publishMessage(message2, ecdhKeypair2.pubKey); + }); + + it("should produce the correct state root and ballot root", async () => { + // The current roots + const emptyBallot = new Ballot(poll.maxValues.maxVoteOptions, poll.treeDepths.voteOptionTreeDepth); + const emptyBallotHash = emptyBallot.hash(); + const ballotTree = new IncrementalQuinTree(STATE_TREE_DEPTH, emptyBallot.hash(), STATE_TREE_ARITY, hash5); + + ballotTree.insert(emptyBallot.hash()); + + poll.stateLeaves.forEach(() => { + ballotTree.insert(emptyBallotHash); + }); + + const currentStateRoot = poll.stateTree?.root; + const currentBallotRoot = ballotTree.root; + + const inputs = poll.processMessages(pollId) as unknown as IProcessMessagesInputs; + + // Calculate the witness + const witness = await circuit.calculateWitness(inputs); + await circuit.expectConstraintPass(witness); + + // The new roots, which should differ, since at least one of the + // messages modified a Ballot or State Leaf + const newStateRoot = poll.stateTree?.root; + const newBallotRoot = poll.ballotTree?.root; + + expect(newStateRoot?.toString()).not.to.be.eq(currentStateRoot?.toString()); + expect(newBallotRoot?.toString()).not.to.be.eq(currentBallotRoot.toString()); + }); + }); + + describe("1 user, 2 messages in different batches", () => { + const maciState = new MaciState(STATE_TREE_DEPTH); + const voteOptionIndex = 1n; + let stateIndex: bigint; + let pollId: bigint; + let poll: Poll; + const messages: Message[] = []; + const commands: PCommand[] = []; + + before(() => { + // Sign up and publish + const userKeypair = new Keypair(new PrivKey(BigInt(1))); + stateIndex = BigInt( + maciState.signUp(userKeypair.pubKey, voiceCreditBalance, BigInt(Math.floor(Date.now() / 1000))), + ); + + pollId = maciState.deployPoll( + BigInt(Math.floor(Date.now() / 1000) + duration), + maxValues, + treeDepths, + messageBatchSize, + coordinatorKeypair, + ); + + poll = maciState.polls.get(pollId)!; + poll.updatePoll(BigInt(maciState.stateLeaves.length)); + + const nothing = new Message(1n, [ + 8370432830353022751713833565135785980866757267633941821328460903436894336785n, + 0n, + 0n, + 0n, + 0n, + 0n, + 0n, + 0n, + 0n, + 0n, + ]); + + const encP = new PubKey([ + 10457101036533406547632367118273992217979173478358440826365724437999023779287n, + 19824078218392094440610104313265183977899662750282163392862422243483260492317n, + ]); + + poll.publishMessage(nothing, encP); + + // First command (valid) + const command = new PCommand( + stateIndex, // BigInt(1), + userKeypair.pubKey, + 1n, // voteOptionIndex, + 2n, // vote weight + 2n, // nonce + pollId, + ); + + const signature = command.sign(userKeypair.privKey); + + const ecdhKeypair = new Keypair(); + const sharedKey = Keypair.genEcdhSharedKey(ecdhKeypair.privKey, coordinatorKeypair.pubKey); + const message = command.encrypt(signature, sharedKey); + messages.push(message); + commands.push(command); + + poll.publishMessage(message, ecdhKeypair.pubKey); + + // fill the batch with nothing messages + for (let i = 0; i < messageBatchSize - 1; i += 1) { + poll.publishMessage(nothing, encP); + } + + // Second command (valid) in second batch (which is first due to reverse processing) + const command2 = new PCommand( + stateIndex, + userKeypair.pubKey, + voteOptionIndex, // voteOptionIndex, + 9n, // vote weight 9 ** 2 = 81 + 1n, // nonce + pollId, + ); + const signature2 = command2.sign(userKeypair.privKey); + + const ecdhKeypair2 = new Keypair(); + const sharedKey2 = Keypair.genEcdhSharedKey(ecdhKeypair2.privKey, coordinatorKeypair.pubKey); + const message2 = command2.encrypt(signature2, sharedKey2); + messages.push(message2); + commands.push(command2); + poll.publishMessage(message2, ecdhKeypair2.pubKey); + }); + + it("should produce the correct state root and ballot root", async () => { + // The current roots + const emptyBallot = new Ballot(poll.maxValues.maxVoteOptions, poll.treeDepths.voteOptionTreeDepth); + const emptyBallotHash = emptyBallot.hash(); + const ballotTree = new IncrementalQuinTree(STATE_TREE_DEPTH, emptyBallot.hash(), STATE_TREE_ARITY, hash5); + + ballotTree.insert(emptyBallot.hash()); + + poll.stateLeaves.forEach(() => { + ballotTree.insert(emptyBallotHash); + }); + + while (poll.hasUnprocessedMessages()) { + const currentStateRoot = poll.stateTree?.root; + const currentBallotRoot = ballotTree.root; + const inputs = poll.processMessages(pollId) as unknown as IProcessMessagesInputs; + + // Calculate the witness + // eslint-disable-next-line no-await-in-loop + const witness = await circuit.calculateWitness(inputs); + // eslint-disable-next-line no-await-in-loop + await circuit.expectConstraintPass(witness); + + // The new roots, which should differ, since at least one of the + // messages modified a Ballot or State Leaf + const newStateRoot = poll.stateTree?.root; + const newBallotRoot = poll.ballotTree?.root; + + expect(newStateRoot?.toString()).not.to.be.eq(currentStateRoot?.toString()); + expect(newBallotRoot?.toString()).not.to.be.eq(currentBallotRoot.toString()); + } + }); + }); + + describe("1 user, 3 messages in different batches", () => { + const maciState = new MaciState(STATE_TREE_DEPTH); + const voteOptionIndex = 1n; + let stateIndex: bigint; + let pollId: bigint; + let poll: Poll; + const messages: Message[] = []; + const commands: PCommand[] = []; + + before(() => { + // Sign up and publish + const userKeypair = new Keypair(new PrivKey(BigInt(1))); + stateIndex = BigInt( + maciState.signUp(userKeypair.pubKey, voiceCreditBalance, BigInt(Math.floor(Date.now() / 1000))), + ); + + pollId = maciState.deployPoll( + BigInt(Math.floor(Date.now() / 1000) + duration), + maxValues, + treeDepths, + messageBatchSize, + coordinatorKeypair, + ); + + poll = maciState.polls.get(pollId)!; + poll.updatePoll(BigInt(maciState.stateLeaves.length)); + + const nothing = new Message(1n, [ + 8370432830353022751713833565135785980866757267633941821328460903436894336785n, + 0n, + 0n, + 0n, + 0n, + 0n, + 0n, + 0n, + 0n, + 0n, + ]); + + const encP = new PubKey([ + 10457101036533406547632367118273992217979173478358440826365724437999023779287n, + 19824078218392094440610104313265183977899662750282163392862422243483260492317n, + ]); + + poll.publishMessage(nothing, encP); + + const commandFinal = new PCommand( + stateIndex, // BigInt(1), + userKeypair.pubKey, + 1n, // voteOptionIndex, + 1n, // vote weight + 3n, // nonce + pollId, + ); + + const signatureFinal = commandFinal.sign(userKeypair.privKey); + + const ecdhKeypairFinal = new Keypair(); + const sharedKeyFinal = Keypair.genEcdhSharedKey(ecdhKeypairFinal.privKey, coordinatorKeypair.pubKey); + const messageFinal = commandFinal.encrypt(signatureFinal, sharedKeyFinal); + messages.push(messageFinal); + commands.push(commandFinal); + + poll.publishMessage(messageFinal, ecdhKeypairFinal.pubKey); + + // First command (valid) + const command = new PCommand( + stateIndex, // BigInt(1), + userKeypair.pubKey, + 1n, // voteOptionIndex, + 2n, // vote weight + 2n, // nonce + pollId, + ); + + const signature = command.sign(userKeypair.privKey); + + const ecdhKeypair = new Keypair(); + const sharedKey = Keypair.genEcdhSharedKey(ecdhKeypair.privKey, coordinatorKeypair.pubKey); + const message = command.encrypt(signature, sharedKey); + messages.push(message); + commands.push(command); + + poll.publishMessage(message, ecdhKeypair.pubKey); + + // fill the batch with nothing messages + for (let i = 0; i < messageBatchSize - 1; i += 1) { + poll.publishMessage(nothing, encP); + } + + // Second command (valid) in second batch (which is first due to reverse processing) + const command2 = new PCommand( + stateIndex, + userKeypair.pubKey, + voteOptionIndex, // voteOptionIndex, + 9n, // vote weight 9 ** 2 = 81 + 1n, // nonce + pollId, + ); + const signature2 = command2.sign(userKeypair.privKey); + + const ecdhKeypair2 = new Keypair(); + const sharedKey2 = Keypair.genEcdhSharedKey(ecdhKeypair2.privKey, coordinatorKeypair.pubKey); + const message2 = command2.encrypt(signature2, sharedKey2); + messages.push(message2); + commands.push(command2); + poll.publishMessage(message2, ecdhKeypair2.pubKey); + }); + + it("should produce the correct state root and ballot root", async () => { + // The current roots + const emptyBallot = new Ballot(poll.maxValues.maxVoteOptions, poll.treeDepths.voteOptionTreeDepth); + const emptyBallotHash = emptyBallot.hash(); + const ballotTree = new IncrementalQuinTree(STATE_TREE_DEPTH, emptyBallot.hash(), STATE_TREE_ARITY, hash5); + + ballotTree.insert(emptyBallot.hash()); + + poll.stateLeaves.forEach(() => { + ballotTree.insert(emptyBallotHash); + }); + + while (poll.hasUnprocessedMessages()) { + const currentStateRoot = poll.stateTree?.root; + const currentBallotRoot = ballotTree.root; + const inputs = poll.processMessages(pollId) as unknown as IProcessMessagesInputs; + + // Calculate the witness + // eslint-disable-next-line no-await-in-loop + const witness = await circuit.calculateWitness(inputs); + // eslint-disable-next-line no-await-in-loop + await circuit.expectConstraintPass(witness); + + // The new roots, which should differ, since at least one of the + // messages modified a Ballot or State Leaf + const newStateRoot = poll.stateTree?.root; + const newBallotRoot = poll.ballotTree?.root; + + expect(newStateRoot?.toString()).not.to.be.eq(currentStateRoot?.toString()); + expect(newBallotRoot?.toString()).not.to.be.eq(currentBallotRoot.toString()); + } + }); + }); }); diff --git a/core/ts/Poll.ts b/core/ts/Poll.ts index 526d85d7d9..a63aee8998 100644 --- a/core/ts/Poll.ts +++ b/core/ts/Poll.ts @@ -581,19 +581,47 @@ export class Poll implements IPoll { currentBallots.unshift(ballot); currentBallotsPathElements.unshift(this.ballotTree!.genProof(Number(stateLeafIndex)).pathElements); - // add the first vote of this ballot - currentVoteWeights.unshift(ballot.votes[0]); - - // create a new quinary tree and add all votes we have so far - const vt = new IncrementalQuinTree(this.treeDepths.voteOptionTreeDepth, 0n, STATE_TREE_ARITY, hash5); - - // fill the vote option tree with the votes we have so far - for (let j = 0; j < this.ballots[0].votes.length; j += 1) { - vt.insert(ballot.votes[j]); + // @note we check that command.voteOptionIndex is valid so < maxVoteOptions + // this might be unnecessary but we do it to prevent a possible DoS attack + // from voters who could potentially encrypt a message in such as way that + // when decrypted it results in a valid state leaf index but an invalid vote option index + if (command.voteOptionIndex < this.maxValues.maxVoteOptions) { + currentVoteWeights.unshift(ballot.votes[Number(command.voteOptionIndex)]); + + // create a new quinary tree and add all votes we have so far + const vt = new IncrementalQuinTree( + this.treeDepths.voteOptionTreeDepth, + 0n, + STATE_TREE_ARITY, + hash5, + ); + + // fill the vote option tree with the votes we have so far + for (let j = 0; j < this.ballots[0].votes.length; j += 1) { + vt.insert(ballot.votes[j]); + } + + // get the path elements for the first vote leaf + currentVoteWeightsPathElements.unshift(vt.genProof(Number(command.voteOptionIndex)).pathElements); + } else { + currentVoteWeights.unshift(ballot.votes[0]); + + // create a new quinary tree and add all votes we have so far + const vt = new IncrementalQuinTree( + this.treeDepths.voteOptionTreeDepth, + 0n, + STATE_TREE_ARITY, + hash5, + ); + + // fill the vote option tree with the votes we have so far + for (let j = 0; j < this.ballots[0].votes.length; j += 1) { + vt.insert(ballot.votes[j]); + } + + // get the path elements for the first vote leaf + currentVoteWeightsPathElements.unshift(vt.genProof(0).pathElements); } - - // get the path elements for the first vote leaf - currentVoteWeightsPathElements.unshift(vt.genProof(0).pathElements); } else { // just use state leaf index 0 currentStateLeaves.unshift(this.stateLeaves[0].copy());