diff --git a/admin/admin.py b/admin/admin.py index dadb6226..307c3901 100644 --- a/admin/admin.py +++ b/admin/admin.py @@ -15,6 +15,7 @@ # You should have received a copy of the GNU Affero General Public License # along with agora_elections. If not, see . +from __future__ import print_function import requests import json import time @@ -31,10 +32,18 @@ import hashlib import codecs import traceback +import string import os.path import os from prettytable import PrettyTable +import random +import shutil + +import warnings as _warnings +import os as _os + +from tempfile import mkdtemp from sqlalchemy import create_engine, select, func, text from sqlalchemy import Table, Column, Integer, String, TIMESTAMP, MetaData, ForeignKey @@ -60,6 +69,92 @@ authapi_db_port = 5432 node = '/usr/local/bin/node' +class TemporaryDirectory(object): + """Create and return a temporary directory. This has the same + behavior as mkdtemp but can be used as a context manager. For + example: + + with TemporaryDirectory() as tmpdir: + ... + + Upon exiting the context, the directory and everything contained + in it are removed. + """ + + def __init__(self, suffix="", prefix="tmp", dir=None): + self._closed = False + self.name = None # Handle mkdtemp raising an exception + self.name = mkdtemp(suffix, prefix, dir) + + def __repr__(self): + return "<{} {!r}>".format(self.__class__.__name__, self.name) + + def __enter__(self): + return self.name + + def cleanup(self, _warn=False): + if self.name and not self._closed: + try: + self._rmtree(self.name) + except (TypeError, AttributeError) as ex: + # Issue #10188: Emit a warning on stderr + # if the directory could not be cleaned + # up due to missing globals + if "None" not in str(ex): + raise + print("ERROR: {!r} while cleaning up {!r}".format(ex, self,), + file=_sys.stderr) + return + self._closed = True + if _warn: + self._warn("Implicitly cleaning up {!r}".format(self), + ResourceWarning) + def __exit__(self, exc, value, tb): + self.cleanup() + + def __del__(self): + # Issue a ResourceWarning if implicit cleanup needed + self.cleanup(_warn=True) + + # XXX (ncoghlan): The following code attempts to make + # this class tolerant of the module nulling out process + # that happens during CPython interpreter shutdown + # Alas, it doesn't actually manage it. See issue #10188 + _listdir = staticmethod(_os.listdir) + _path_join = staticmethod(_os.path.join) + _isdir = staticmethod(_os.path.isdir) + _islink = staticmethod(_os.path.islink) + _remove = staticmethod(_os.remove) + _rmdir = staticmethod(_os.rmdir) + _warn = _warnings.warn + + def _rmtree(self, path): + # Essentially a stripped down version of shutil.rmtree. We can't + # use globals because they may be None'ed out at shutdown. + for name in self._listdir(path): + fullname = self._path_join(path, name) + try: + isdir = self._isdir(fullname) and not self._islink(fullname) + except OSError: + isdir = False + if isdir: + self._rmtree(fullname) + else: + try: + self._remove(fullname) + except OSError: + pass + try: + self._rmdir(path) + except OSError: + pass + + + + + + + def get_local_hostport(): return app_host, app_port @@ -286,7 +381,7 @@ def cast_votes(cfg, args): vote_string = json.dumps(vote) vote_hash = hashlib.sha256(vote_string).hexdigest() vote = { - "vote": json.dumps(vote), + "vote": vote_string, "vote_hash": vote_hash } @@ -607,7 +702,7 @@ def encrypt(cfg, args): output, error = subprocess.Popen(["bash", "encrypt.sh", pkPath, votesPath, str(votesCount)], stdout = subprocess.PIPE).communicate() print("Received encrypt.sh output (" + str(len(output)) + " chars)") - parsed = json.loads(output) + parsed = json.loads(output.decode('utf-8')) print("Writing file to " + ctextsPath) with codecs.open(ctextsPath, encoding='utf-8', mode='w+') as votes_file: @@ -640,9 +735,9 @@ def get_hmac(cfg, userId, objType, objId, perm): import hmac secret = shared_secret - now = 1000*long(time.time()) + now = 1000*int(time.time()) message = "%s:%s:%d:%s:%d" % (userId, objType, objId, perm, now) - _hmac = hmac.new(str(secret), str(message), hashlib.sha256).hexdigest() + _hmac = hmac.new(str.encode(secret), str.encode(message), hashlib.sha256).hexdigest() ret = 'khmac:///sha-256;%s/%s' % (_hmac, message) return ret @@ -671,18 +766,20 @@ def main(argv): dump_votes [election_id, [election_id], ...]: dump voter ids list_votes : list votes list_elections: list elections +cast_votes : cast votes from ciphertetxs dump_pks : dumps pks for an election (public datastore) encrypt : encrypts votes using scala (public key must be in datastore) encryptNode : encrypts votes using node (public key must be in datastore) dump_votes : dumps votes for an election (private datastore) change_social : changes the social netoworks share buttons configuration -authapi_ensure_acls --acls-path : ensure that the acls inside acl_path exist. +authapi_ensure_acls --acls-path : ensure that the acls inside acl_path exist ''') parser.add_argument('--ciphertexts', help='file to write ciphertetxs (used in dump, load and encrypt)') parser.add_argument('--acls-path', help='''the file has one line per acl with format: '(email:email@example.com|tlf:+34666777888),permission_name,object_type,object_id,user_election_id' ''') parser.add_argument('--plaintexts', help='json file to read votes from when encrypting', default = 'votes.json') parser.add_argument('--filter-config', help='file with filter configuration', default = None) parser.add_argument('--encrypt-count', help='number of votes to encrypt (generates duplicates if more than in json file)', type=int, default = 0) + parser.add_argument('--vote-count', help='number of votes to generate', type=int, default = 0) parser.add_argument('--results-config', help='config file for agora-results') parser.add_argument('--voter-ids', help='json file with list of valid voter ids to tally (used with tally_voter_ids)') parser.add_argument('--ips-log', help='') diff --git a/admin/batch.py b/admin/batch.py index 6fd16076..a506beed 100644 --- a/admin/batch.py +++ b/admin/batch.py @@ -104,12 +104,6 @@ def main(argv): elif args.command == 'list-tally-with-ids': for eid in args.election_ids: - print('next id %d, dumping votes with matching ids (in private datastore)' % eid) - ret = cycle.dump_votes_with_ids(eid) - if ret in [400, 500]: - print("dump_votes_with_ids returned %d, continuing without it" % ret) - continue - print('next id %d, stopping election' % eid) ret = cycle.stop(eid) if ret in [400, 500]: @@ -223,12 +217,6 @@ def main(argv): else: next_id = cfg['id'] - print('next id %d, dumping votes with matching ids (in private datastore)' % next_id) - ret = cycle.dump_votes_with_ids(next_id) - if ret in [400, 500]: - print("dump_votes_with_ids returned %d, continuing without it" % ret) - continue - print('next id %d, stopping election' % next_id) ret = cycle.stop(next_id) if ret in [400, 500]: diff --git a/admin/test.jmx b/admin/test.jmx new file mode 100644 index 00000000..ead73f89 --- /dev/null +++ b/admin/test.jmx @@ -0,0 +1,180 @@ + + + + + + false + false + + + + + + + + continue + + false + ${loop_count} + + ${num_threads} + ${rampup_secs} + 1489135288000 + 1489135288000 + false + + + + + + + + + ${server_host} + ${server_port} + + + + + + 6 + + + + true + + + + false + ${ballot} + = + + + + + + + + https + + ${service_path}/api/election/${electionid}/voter/${userid} + POST + true + false + true + false + false + + + + + false + + saveConfig + + + true + true + true + + true + true + true + true + false + true + true + false + false + false + true + false + false + false + true + 0 + true + true + true + true + true + + + + + + + + false + standard + org.apache.jmeter.protocol.http.control.HC4CookieHandler + + + + ${csv_file_path} + + electionid,userid,ballot,khmac + | + false + false + false + shareMode.all + + + + + + Authorization + ${khmac} + + + Content-Type + application/json;charset=UTF-8 + + + + + + + + num_threads + ${__P(num_threads,10)} + = + + + loop_count + ${__P(loop_count,100)} + = + + + rampup_secs + ${__P(rampup_secs,10)} + = + + + csv_file_path + ${__P(csv_file_path,/home/agoraelections/jmeter/ciphertexts-khmac.csv)} + = + + + server_host + ${__P(server_host,localhost)} + = + + + server_port + ${__P(server_port,9000)} + = + + + service_path + ${__P(service_path,/elections)} + = + + + + + + + + diff --git a/app/commands/Console.scala b/app/commands/Console.scala new file mode 100644 index 00000000..a9fc8ee3 --- /dev/null +++ b/app/commands/Console.scala @@ -0,0 +1,1273 @@ +/** + * This file is part of agora_elections. + * Copyright (C) 2017 Agora Voting SL + + * agora_elections is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License. + + * agora_elections is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + + * You should have received a copy of the GNU Affero General Public License + * along with agora_elections. If not, see . +**/ +package commands + +import models._ +import utils.JsonFormatters._ +import utils.Crypto + +import java.util.concurrent.Executors +import scala.concurrent._ + +import javax.crypto.Mac +import javax.xml.bind.DatatypeConverter +import scala.math.BigInt +import java.security.KeyStore +import scala.concurrent.forkjoin._ +import scala.collection.mutable.ArrayBuffer +import scala.util.{Try, Success, Failure} + +import play.api.libs.concurrent.Execution.Implicits.defaultContext +import play.libs.Akka + +import play.api.libs.json._ + +import java.nio.file.{Paths, Files, Path} +import java.nio.file.StandardOpenOption._ +import java.nio.charset.StandardCharsets +import java.io.File +import java.io.RandomAccessFile +import java.nio._ + +import play.api.Play.current +import play.api.libs.ws._ +import play.api.libs.ws.ning.NingAsyncHttpClientConfigBuilder +import play.api.libs.ws.ning.NingWSClient +import play.api.mvc._ +import play.api.http.{Status => HTTP} + + +case class PlaintextError(message: String) extends Exception(message) +case class DumpPksError(message: String) extends Exception(message) +case class BallotEncryptionError(message: String) extends Exception(message) +case class GetElectionInfoError(message: String) extends Exception(message) +case class EncodePlaintextError(message: String) extends Exception(message) +case class EncryptionError(message: String) extends Exception(message) +case class ElectionIdsFileError(message: String) extends Exception(message) + +/** + * A simple class to write lines to a single file, in a multi-threading safe way + */ +class FileWriter(path: Path) +{ + Files.deleteIfExists(path) + Files.createFile(path) + + def write(content: String) : Future[Unit] = Future + { + this.synchronized + { + Files.write(path, content.getBytes(StandardCharsets.UTF_8), APPEND) + } + } +} + +trait ConsoleInterface { + def showHelp() : Unit + def parse_args(args: Array[String]) : Int + def gen_votes() : Future[Unit] + def add_khmacs() : Future[Unit] + def gen_plaintexts() : Future[Unit] +} + +class ConsoleImpl extends ConsoleInterface { + + // number of votes to create + var vote_count : Long = 0 + // http or https + var http_type = "https" + // hostname of the agora-elections server + var host = "localhost" + // path to the service inside the host + var service_path = "elections/" + // agora-elections port + var port : Long = 9000 + // default plaintext path + var plaintexts_path = "plaintexts.txt" + // default ciphertexts path + var ciphertexts_path = "ciphertexts.csv" + // default ciphertexts (with khmac) path + var ciphertexts_khmac_path = "ciphertexts-khmac.csv" + var shared_secret = "" + // default voter id length (number of characters) + var voterid_len : Int = 28 + val voterid_alphabet: String = "0123456789abcdef" + var keystore_path: Option[String] = None + var election_ids_path : String = "election_ids.txt" + + // In order to make http requests with Play without a running Play instance, + // we have to do this + // copied from https://www.playframework.com/documentation/2.3.x/ScalaWS + val clientConfig = new DefaultWSClientConfig() + val secureDefaults:com.ning.http.client.AsyncHttpClientConfig = new NingAsyncHttpClientConfigBuilder(clientConfig).build() + val builder = new com.ning.http.client.AsyncHttpClientConfig.Builder(secureDefaults) + builder.setCompressionEnabled(true) + val secureDefaultsWithSpecificOptions:com.ning.http.client.AsyncHttpClientConfig = builder.build() + implicit val wsClient = new play.api.libs.ws.ning.NingWSClient(secureDefaultsWithSpecificOptions) + + /** + * Generates random plaintext ballots for a number of elections. + * It generates --vote-count number of plaintexts, for the elections mentioned + * on file --election-ids. The election-ids file should have an election id on + * each line. Election ids should not be repeated. The generated plaintexts + * will be saved on the file set by option --plaintexts (or its default + * value). The output format of the plaintexts file is compatible with the + * required input for command gen_votes. The number of votes should be at + * least as big as the number of elections. + */ + def gen_plaintexts() + : Future[Unit] = + { + val promise = Promise[Unit]() + Future + { + promise completeWith + { + getElectionsSet() + .flatMap + { + electionsSet => get_election_info_all(electionsSet) + } + .flatMap + { + electionsInfoMap => generate_save_plaintexts(electionsInfoMap) + } + } + } + .recover + { + case error: Throwable => promise failure error + } + promise.future + } + + /** + * Parse program arguments + */ + def parse_args(args: Array[String]) : Int = + { + var arg_index : Int = 0 + while (arg_index + 2 < args.length) + { + // number of ballots to create + if ("--vote-count" == args(arg_index + 1)) + { + vote_count = args(arg_index + 2).toLong + arg_index += 2 + } + else if ("--plaintexts" == args(arg_index + 1)) + { + plaintexts_path = args(arg_index + 2) + arg_index += 2 + } + else if ("--ciphertexts" == args(arg_index + 1)) + { + ciphertexts_path = args(arg_index + 2) + arg_index += 2 + } + else if ("--ciphertexts-khmac" == args(arg_index + 1)) + { + ciphertexts_khmac_path = args(arg_index + 2) + arg_index += 2 + } + else if ("--voterid-len" == args(arg_index + 1)) + { + voterid_len = args(arg_index + 2).toInt + arg_index += 2 + } + else if ("--ssl" == args(arg_index + 1)) + { + if ("true" == args(arg_index + 2)) + { + http_type = "https" + } + else if ("false" == args(arg_index + 2)) + { + http_type = "http" + } + else + { + throw new java.lang.IllegalArgumentException(s"Invalid --ssl option: " + args(arg_index + 2) + ". Valid values: true, false") + } + arg_index += 2 + } + else if ("--shared-secret" == args(arg_index + 1)) + { + shared_secret = args(arg_index + 2) + arg_index += 2 + } + else if ("--service-path" == args(arg_index + 1)) + { + service_path = args(arg_index + 2) + arg_index += 2 + } + else if ("--election-ids" == args(arg_index + 1)) + { + election_ids_path = args(arg_index + 2) + arg_index += 2 + } + else if ("--host" == args(arg_index + 1)) + { + host = args(arg_index + 2) + arg_index += 2 + } + else if ("--port" == args(arg_index + 1)) + { + port = args(arg_index + 2).toLong + if (port <= 0 || port > 65535) + { + throw new java.lang.IllegalArgumentException(s"Invalid port $port") + } + arg_index += 2 + } + else + { + throw new java.lang.IllegalArgumentException("Unrecognized argument: " + + args(arg_index + 1)) + } + } + arg_index + } + + /** + * Given a list of plaintexts, it generates their ciphertexts + */ + def gen_votes() : Future[Unit] = + { + val promise = Promise[Unit]() + Future + { + promise completeWith + { + parsePlaintexts() flatMap + { + case (ballotsList, electionsSet) => + val dumpPksFuture = dump_pks_elections(electionsSet) + get_election_info_all(electionsSet) flatMap + { + electionsInfoMap => + dumpPksFuture flatMap + { + pksDumped => + encryptBallots(ballotsList, electionsInfoMap) + } + } + } + } + } + .recover + { + case error: Throwable => promise failure error + } + promise.future + } + + /** + * Reads a file created by gen_votes and adds a khmac to each ballot + * + * The objective of this command is to prepare the ballots to be sent by + * jmeter. + * + * Encrypting the plaintext ballots takes some time, and it can be + * done at any moment. However, the khmacs will only be valid for a certain + * period of time, and therefore, it can be useful to first encrypt the + * ballots and only generate the khmacs just before sending the ballots to + * the server. + * + * The format of each line of the ciphertexts file that this command reads + * should be: + * + * electionId|voterId|ballot + * + * Notice that the separator character is '|' + * + * The format of each line of the ciphertexts_khmac file that this command + * creates is: + * + * electionId|voterId|ballot|khmac + * + * Notice that it simply adds the khmac. + */ + def add_khmacs() : Future[Unit] = + { + val promise = Promise[Unit]() + Future + { + // check that the file we want to read exists + if (Files.exists(Paths.get(ciphertexts_path))) + { + val now = Some(System.currentTimeMillis / 1000) + val writer = new FileWriter(Paths.get(ciphertexts_khmac_path)) + promise completeWith + { + Future.traverse (io.Source.fromFile(ciphertexts_path).getLines()) + { + file_line => + val array = file_line.split('|') + val eid = array(0).toLong + val voterId = array(1) + val khmac = get_khmac(voterId, "AuthEvent", eid, "vote", now) + writer.write(file_line + "|" + khmac + "\n") + } + .map + { + _ => () + } + } + } + else + { + throw new java.io.FileNotFoundException("tally does not exist") + } + } + .recover + { + case error: Throwable => promise failure error + } + promise.future + } + + /** + * Prints to the standard output how to use this program + */ + def showHelp() = + { + System.out.println( +"""NAME + | commands.Console - Generate and send votes for load testing and benchmarking + | + |SYNOPSIS + | commands.Console [gen_plaintexts|gen_votes|add_khmacs] + | [--vote-count number] [--plaintexts path] [--ciphertexts path] + | [--ciphertexts-khmac path] [--voterid-len number] + | [--shared-secret password] [--port port] [--ssl boolean] + | [--host hostname] [--service-path path] [--election-ids path] + | + |DESCRIPTION + | In order to run this program, use: + | activator "runMain commands.Console [arguments]" + | + | Possible commands + | + | gen_plaintexts + | Generates plaintext ballots covering every option for every question + | for each election id. Use --vote-count to set the number of plaintexts + | to be generated, which should be at least the number of elections. + | + | gen_votes + | Given a plaintext file where each string line contains a plaintext, + | it generates a ciphertext file with encrypted ballots. Encrypting + | ballots is a computationally intensive process, so it can take a + | while. The user ids are generated in a random fashion. + | + | add_khmacs + | Given a ciphertexts file where each string line contains an encrypted + | ballot, it adds a khmac to each line and saves the new file. This step + | is faster than encrypting the ballots and it prepares them to be sent + | to the ballot box with jmeter.The khmacs are only valid for a period + | of time and that's why they are generated just before sending the + | ballots. + | + | Possible arguments + | + | --vote-count + | Number of ballots to be generated. If the number of plaintexts is less + | than the vote count, then the plaintexts will be repeated till the + | desired number is achieved. + | Default value: 0 + | + | --plaintexts + | Path to the plaintexts file. In this file, each line represents a + | ballot with the chosen voting options. For example: + | 39||0|22,4 + | In this line, the election id is 39, on the first question the chosen + | option is a blank vote, on the first option it's voting to option 0, + | and on the last question it's voting to options 22 and 4. + | Notice that as each line indicates the election id, this file can be + | used to generate ballots for multiple elections. + | Default value: plaintexts.txt + | + | --ciphertexts + | The path to the ciphertexts file. This file is created by the + | gen_votes command and it contains the encrypted ballots generated from + | the plaintexts file. In this file, each line represents an encrypted + | ballot, in the following format: + | election_id|voter_id|ballot + | This is an intermediate file, because as it doesn't contain the + | khmacs, it's not ready to be used by jmeter. + | Default value: ciphertexts.csv + | + | --ciphertexts-khmac + | The path to the final ciphertexts file, which includes the khmacs. + | This file is generated by the add_khmacs command. The file format is + | the same as the ciphertexts file, except that it adds the khmac to + | each ballot: + | election_id|voter_id|ballot|khmac + | This is the file that will be fed to jmeter + | Default value: ciphertexts-khmac.csv + | + | --voterid-len + | Voter ids for each ballot are generated with random numbers and + | represented in hexadecimal format. This parameter configures the + | number of hexadecimal characters of the voter ids. + | Default value: 28 + | + | --shared-secret + | The shared secret required for authentication and creating khmacs. + | This value can be found on the config.yml + | agora.agora_elections.shared_secret variable. + | Default value: + | + | --service-path + | The location of service agora_elections in the host server. For + | example if a call to the server is: + | https://agora:443/elections/api/election/39 + | Then the service path is "elections/". + | Default value: elections/ + | + | --port + | Port number of the agora_elections service. + | Default value: 443 + | + | --host + | Hostname of the agora_elections service. + | Default value: localhost + | + | --ssl + | Enables or disables https. If true, http requests to the + | agora_elections service will use https. + | Usually, the agora_elections service will use a self-signed + | certificate. To make this program to accept the certificate, use + | -Djavax.net.ssl.trustStore=/path/to/jks/truststore and + | -Djavax.net.ssl.trustStorePassword=pass with a jks truststore that + | includes the agora_elections certificate. + | Possible values: true|false + | Default value: true + | + | --election-ids path + | Path to a file containing the election ids. Required parameter by + | command gen_plaintexts. + | + |""".stripMargin) + } + + /** + * Processes a plaintext ballot line + * A plaintext ballot will have the following format: + * id|option1,option2|option1|| + * + * - The id is a number that corresponds to the election id + * - A separator '|' is used to signal the end of the election id + * - The chosen options for each answer come after the election id + * - The options for an answer are separated by a ',' + * - Each answer is separated by a '|' + * - A blank vote for an answer is represented with no characters + * - For example '||' means a blank vote for the corresponding answer + */ + def processPlaintextLine( + line: String, + lineNumber: Long) + : PlaintextBallot = + { + // in this variable we keep adding the characters that will form a complete number + var strIndex: Option[String] = None + // state: either reading the election index or the election answers + var state = PlaintextBallot.ID + // the ballot to be returned + var ballot = new PlaintextBallot() + // for each question in the election, there will be zero (blank vote) to n possible chosen options + // We'll keep here the options chosen for each answer as we are reading them + var optionsBuffer: Option[ArrayBuffer[Long]] = None + // buffer that holds the chosen options for the answers + var answersBuffer: ArrayBuffer[PlaintextAnswer] = ArrayBuffer[PlaintextAnswer]() + + // iterate through all characters in the string line + for (i <- 0 until line.length) + { + val c = line.charAt(i) + // keep reading digits till we get the whole number + if(c.isDigit) + { + strIndex match + { + // add a character to the string containing a number + case Some(strIndexValue) => + strIndex = Some(strIndexValue + c.toString) + case None => () + // add the first character to the string containing a number + strIndex = Some(c.toString) + } + } + // it's not a digit + else + { + // state: reading election ID + if (PlaintextBallot.ID == state) + { + if ('|' != c) + { + throw PlaintextError(s"Error on line $lineNumber, character $i: character separator '|' not found after election index . Line: $line") + } + strIndex match + { + case Some(strIndexValue) => + ballot = new PlaintextBallot(strIndex.get.toLong, ballot.answers) + strIndex = None + optionsBuffer = Some(ArrayBuffer[Long]()) + state = PlaintextBallot.ANSWER + case None => + throw PlaintextError(s"Error on line $lineNumber, character $i: election index not recognized. Line: $line") + } + } + // state: reading answers to each question + else if (PlaintextBallot.ANSWER == state) + { + optionsBuffer match + { + case Some(optionsBufferValue) => + // end of this question, add question to buffer + if ('|' == c) + { + if (strIndex.isDefined) + { + optionsBufferValue += strIndex.get.toLong + strIndex = None + } + answersBuffer += PlaintextAnswer(optionsBufferValue.toArray) + optionsBuffer = Some(ArrayBuffer[Long]()) + } + // add chosen option to buffer + else if(',' == c) + { + strIndex match + { + case Some(strIndexValue) => + optionsBufferValue += strIndexValue.toLong + strIndex = None + case None => + throw PlaintextError(s"Error on line $lineNumber, character $i: option number not recognized before comma on question ${ballot.answers.length}. Line: $line") + } + } + else + { + throw PlaintextError(s"Error on line $lineNumber, character $i: invalid character: $c. Line: $line") + } + case None => + PlaintextError(s"Error on line $lineNumber, character $i: unknown error, invalid state. Line: $line") + } + } + } + } + // add the last answer + optionsBuffer match + { + case Some(optionsBufferValue) => + // read last option of last answer, if there's any + if (strIndex.isDefined) + { + optionsBufferValue += strIndex.get.toLong + } + answersBuffer += PlaintextAnswer(optionsBufferValue.toArray) + case None => + throw PlaintextError(s"Error on line $lineNumber: unknown error, invalid state. Line: $line") + } + ballot = new PlaintextBallot(ballot.id, answersBuffer.toArray) + ballot + } + + + /** + * Opens the plaintexts file, and reads and parses each line. + * See Console.processPlaintextLine() comments for more info on the format + * of the plaintext file. + */ + def parsePlaintexts() + : Future[ + (scala.collection.immutable.List[PlaintextBallot], + scala.collection.immutable.Set[Long])] = + { + val promise = Promise[(scala.collection.immutable.List[PlaintextBallot], scala.collection.immutable.Set[Long])]() + Future + { + val path = Paths.get(plaintexts_path) + // Check that the plaintexts file exists + if (Files.exists(path)) + { + // buffer with all the parsed ballots + val ballotsList = scala.collection.mutable.ListBuffer[PlaintextBallot]() + // set of all the election ids + val electionsSet = scala.collection.mutable.LinkedHashSet[Long]() + // read all lines + io.Source.fromFile(plaintexts_path).getLines().zipWithIndex.foreach + { + case (line, number) => + // parse line + val ballot = processPlaintextLine(line, number) + ballotsList += ballot + electionsSet += ballot.id + } + if ( electionsSet.isEmpty || ballotsList.isEmpty ) + { + throw PlaintextError("Error: no ballot found") + } + else + { + promise success ( ( ballotsList.sortBy(_.id).toList, electionsSet.toSet ) ) + } + } + else + { + throw new java.io.FileNotFoundException(s"plaintext file ${path.toAbsolutePath.toString} does not exist or can't be opened") + } + } + .recover + { + case error: Throwable => promise failure error + } + promise.future + } + + /** + * Generate khmac + */ + def get_khmac( + userId: String, + objType: String, + objId: Long, + perm: String, + nowOpt: Option[Long] = None + ) + : String = + { + val now: Long = nowOpt match + { + case Some(time) => time + case None => System.currentTimeMillis / 1000 + } + val message = s"$userId:$objType:$objId:$perm:$now" + val hmac = Crypto.hmac(shared_secret, message) + val khmac = s"khmac:///sha-256;$hmac/$message" + khmac + } + + /** + * Makes an http request to agora-elections to get the election info. + * Returns the parsed election info + */ + def get_election_info(electionId: Long) : Future[ElectionDTO] = + { + val promise = Promise[ElectionDTO] + Future + { + val url = s"$http_type://$host:$port/${service_path}api/election/$electionId" + wsClient.url(url) .get() .map + { + response => + if(response.status == HTTP.OK) + { + val dto = (response.json \ "payload").validate[ElectionDTO].get + promise success dto + } + else + { + promise failure GetElectionInfoError( + s"HTTP GET request to $url returned status: ${response.status} and body: ${response.body}") + } + } + .recover + { + case error: Throwable => promise failure error + } + } + .recover + { + case error: Throwable => promise failure error + } + promise.future + } + + /** + * Given a set of election ids, it returns a map of the election ids and their + * election info. + */ + def get_election_info_all( + electionsSet: scala.collection.immutable.Set[Long] + ) + : Future[scala.collection.mutable.HashMap[Long, ElectionDTO]] = + { + val promise = Promise[scala.collection.mutable.HashMap[Long, ElectionDTO]]() + Future + { + val map = scala.collection.mutable.HashMap[Long, ElectionDTO]() + promise completeWith + { + Future.traverse(electionsSet) + { + eid => + get_election_info(eid) map + { + dto : ElectionDTO => + // using synchronized to make it thread-safe + this.synchronized + { + map += (dto.id -> dto) + } + } + } + .map + { + _ => map + } + } + } + .recover + { + case error: Throwable => promise failure error + } + promise.future + } + + /** + * Makes an HTTP request to agora-elections to dump the public keys for a + * given election id. + */ + def dump_pks(electionId: Long): Future[Unit] = + { + val promise = Promise[Unit]() + Future + { + val auth = get_khmac("", "AuthEvent", electionId, "edit") + val url = s"$http_type://$host:$port/${service_path}api/election/$electionId/dump-pks" + wsClient.url(url) + .withHeaders("Authorization" -> auth) + .post(Results.EmptyContent()).map + { + response => + if(response.status == HTTP.OK) + { + promise success ( () ) + } + else + { + promise failure DumpPksError(s"HTTP POST request to $url returned status: ${response.status} and body: ${response.body}") + } + } + .recover + { + case error: Throwable => promise failure error + } + } + .recover + { + case error: Throwable => promise failure error + } + promise.future + } + + /** + * Given a set of election ids, it returns a future that will be completed + * when all the public keys of all those elections have been dumped. + */ + def dump_pks_elections( + electionsSet: scala.collection.immutable.Set[Long] + ) + : Future[Unit] = + { + val promise = Promise[Unit]() + Future + { + promise completeWith + { + Future.traverse( electionsSet ) ( x => dump_pks(x) ) map + { + _ => () + } + } + } + .recover + { + case error: Throwable => promise failure error + } + promise.future + } + + /** + * Given a plaintext and the election info, it encodes each question's answers + * into a number, ready to be encrypted + */ + def encodePlaintext( + ballot: PlaintextBallot, + dto: ElectionDTO + ) + : Array[BigInt] = + { + var array = new Array[BigInt](ballot.answers.length) + if (dto.configuration.questions.length != ballot.answers.length) + { + val strBallot = Json.toJson(ballot).toString + val strDto = Json.toJson(dto).toString + throw EncodePlaintextError( + s"""Plaintext ballot:\n$strBallot\nElection dto:\n$strDto\n + |Error: wrong number of questions on the plaintext ballot: + |${dto.configuration.questions.length} != ${ballot.answers.length}""") + } + for (i <- 0 until array.length) + { + Try + { + val numChars = ( dto.configuration.questions(i).answers.length + 2 ).toString.length + // holds the value of the encoded answer, before converting it to a Long + var strValue : String = "" + val answer = ballot.answers(i) + for (j <- 0 until answer.options.length) + { + // sum 1 as it would cause encoding problems, as zeros on the left are + // removed in the end + val optionStrBase = ( answer.options(j) + 1 ).toString + // Each chosen option needs to have the same length in number of + // characters/digits, so we fill in with zeros on the left + strValue += "0" * (numChars - optionStrBase.length) + optionStrBase + } + // blank vote + if (0 == answer.options.length) + { + val optionStrBase = ( dto.configuration.questions(i).answers.length + 2 ).toString + strValue += "0" * (numChars - optionStrBase.length) + optionStrBase + } + // Convert to long. Notice that the zeros on the left added by the last + // chosen option won't be included + array(i) = BigInt(strValue) + } + match + { + case Failure(error) => + val strBallot = Json.toJson(ballot).toString + val strDto = Json.toJson(dto).toString + throw EncodePlaintextError(s"Plaintext ballot:\n$strBallot\nElection dto:\n$strDto\nQuestion index where error was generated: $i\nError: ${error.getMessage}") + case _ => + } + } + array + } + + /** + * Generate a random string with a given length and alphabet + * Note: This is not truly random, it uses a pseudo-random generator + */ + def gen_rnd_str(len: Int, choices: String) : String = + { + (0 until len) map + { + _ => choices( scala.util.Random.nextInt(choices.length) ) + } mkString + } + + /** + * Generate a random voter id + */ + def generate_voterid() : String = + { + gen_rnd_str(voterid_len, voterid_alphabet) + } + + /** + * Given an election id and an encrypted ballot, it generates a String text + * line in the following format: + * electionId|voterId|vote + * Notice that it automatically adds a random voter id + */ + def generate_vote_line(id: Long, ballot: EncryptedVote) : String = + { + val vote = Json.toJson(ballot).toString + val voteHash = Crypto.sha256(vote) + val voterId = generate_voterid() + val voteDTO = Json.toJson(VoteDTO(vote, voteHash)).toString + val line = id.toString + "|" + generate_voterid() + "|" + voteDTO + "\n" + line + } + + /** + * Given a map of election ids and election info, it returns a map with the + * election public keys + */ + def get_pks_map( + electionsInfoMap: scala.collection.mutable.HashMap[Long, ElectionDTO] + ) + : scala.collection.mutable.HashMap[Long, Array[PublicKey]] = + { + val pksMap = scala.collection.mutable.HashMap[Long, Array[PublicKey]]() + // map election ids and public keys + electionsInfoMap foreach + { + case (key, value) => + val strPks = value.pks match + { + case Some(strPks) => + strPks + case None => + val strDto = Json.toJson(value).toString + throw new GetElectionInfoError(s"Error: no public keys found for election $key. Election Info: $strDto") + } + val jsonPks = Try + { + Json.parse(strPks) + } + match + { + case Success(jsonPks) => + jsonPks + case Failure(error) => + throw new GetElectionInfoError(s"Error: public keys with invalid JSON format for election $key.\nPublic keys: $strPks.\n${error.getMessage}") + } + jsonPks.validate[Array[PublicKey]] match + { + case s :JsSuccess[Array[PublicKey]] => + val pks = s.get + pksMap += (key -> pks) + case error : JsError => + throw new GetElectionInfoError(s"Error: public keys with invalid Array[PublicKey] format for election $key.\nPublic keys ${jsonPks.toString}\n") + } + } + pksMap + } + + /** + * Given a list of plaintext ballots and a map of election ids and their + * election info, it generates and encrypts the ballots, saving them to a + * file. + * Read generate_vote_line for more info on the output file format. + */ + def encryptBallots( + ballotsList: scala.collection.immutable.List[PlaintextBallot], + electionsInfoMap: scala.collection.mutable.HashMap[Long, ElectionDTO] + ) + : Future[Unit] = + { + val promise = Promise[Unit]() + Future + { + val pksMap = get_pks_map(electionsInfoMap) + // base list of encoded plaintext ballots + val votes = ballotsList.par.map + { + ballot : PlaintextBallot => + (ballot.id, encodePlaintext( ballot, electionsInfoMap.get(ballot.id).get ) ) + }.seq + val writer = new FileWriter(Paths.get(ciphertexts_path)) + // we need to generate vote_count encrypted ballots, iterate the + // plaintexts list as many times as we need + val futuresArray = + (0 until vote_count.toInt).toStream.map + { + index => + val writePromise = Promise[Unit]() + Future + { + val (electionId, plaintext) = votes(index % votes.size) + val pks = pksMap.get(electionId).get + if (pks.length != plaintext.length) + { + throw new EncryptionError(s"${pks.length} != ${plaintext.length}") + } + val encryptedVote = Crypto.encryptBig(pks, plaintext) + val line = generate_vote_line(electionId, encryptedVote) + writePromise completeWith writer.write(line) + } + .recover + { + case error: Throwable => writePromise failure error + } + writePromise.future + } + promise completeWith + { + Future.sequence (futuresArray) + .map + { + _ => () + } + } + } + .recover + { + case error: Throwable => promise failure error + } + promise.future + } + + /** + * Generates 'numVotes' random plaintext ballots for election id 'eid', using + * election info 'dto'. + * Each plaintext ballot will fill a string line and it will have the format + * described by method @processPlaintextLine: + * eid||option1,option2|option1|| + */ + + def generate_plaintexts_for_eid( + eid: Long, + numVotes: Long, + dto: ElectionDTO + ) + : String = + { + // output variable + var outText: String = "" + // election id, as a string + val sEid = eid.toString + // iterate over all the plaintext ballots that need be generated + for (i <- 0L until numVotes) + { + // variable that contains one plaintext ballot + var line: String = sEid + // iterate over all the questions this election has + for (question <- dto.configuration.questions) + { + // add questions separator + line += "|" + val diff = question.max - question.min + // buffer with all possible options for this question + val optionsBuffer : scala.collection.mutable.ArrayBuffer[Long] = + (0L until question.answers.size.toLong) + .to[scala.collection.mutable.ArrayBuffer] + val num_answers = question.min + scala.util.Random.nextInt(diff + 1) + // iterate over the number of answers to be generated for this question + for (j <- 0 until num_answers) + { + // add options separator + if (0 != j) + { + line += "," + } + // select a random, non-repeated option + val option = optionsBuffer(scala.util.Random.nextInt(optionsBuffer.size)) + // remove this option for the next operation, to avoid options repetition + optionsBuffer -= option + // add option to plaintext ballot line + line += option.toString + } + } + // add plaintext ballot line to output variable + outText += line + "\n" + } + outText + } + + /** + * Given a map election id -> election info, and the total number of + * plaintext ballots (vote_count) to be generated, it generates another map + * election id -> (votes, election info). + * We'll generate between a and a+1 plaintext ballots for each election id, + * where a = vote_count / num elections. + */ + + def generate_map_num_votes_dto( + electionsInfoMap: scala.collection.mutable.HashMap[Long, ElectionDTO] + ) + : scala.collection.mutable.HashMap[Long, (Long, ElectionDTO)] + = + { + val numElections = electionsInfoMap.size + if (0 == vote_count) + { + throw new java.lang.IllegalArgumentException(s"Missing argument: --vote-count") + } + else if (vote_count < numElections) + { + throw new java.lang.IllegalArgumentException( + s"vote-count: $vote_count is less than the number of elections ($numElections)") + } + // minimum number of votes per election + val votesFloor = (vote_count.toFloat / numElections.toFloat).floor.toLong + // number of votes already "used" + var votesSoFar: Long = 0 + // number of elections already used + var electionsSoFar: Long = 0 + // the output variable + val electionsVotesDtoMap = scala.collection.mutable.HashMap[Long, (Long, ElectionDTO)]() + // + electionsInfoMap.keys.toList.sorted foreach + { + case eid => + val dto = electionsInfoMap.get(eid).get + // votes to be generated for this election + val votesToGenThisEid = if (vote_count - votesSoFar > votesFloor*(numElections - electionsSoFar)) + { + votesFloor + 1 + } + else + { + votesFloor + } + // add element to map + electionsVotesDtoMap += (eid -> (votesToGenThisEid, dto)) + votesSoFar += votesToGenThisEid + electionsSoFar += 1 + } + electionsVotesDtoMap + } + + /** + * Given a map of election id to election info, it generates --vote-count + * number of plaintext ballots and save them on --plaintexts + */ + + def generate_save_plaintexts( + electionsInfoMap: scala.collection.mutable.HashMap[Long, ElectionDTO] + ) + : Future[Unit] = + { + val promise = Promise[Unit]() + Future + { + // get number of votes per election id, and election info, in a map + val electionsVotesDtoMap = generate_map_num_votes_dto(electionsInfoMap) + // writer to write into the output plaintexts file in a thread-safe way + val writer = new FileWriter(Paths.get(plaintexts_path)) + promise completeWith { + Future.traverse (electionsVotesDtoMap) + { + case (eid, (numVotes, dto)) => + val writePromise = Promise[Unit]() + Future + { + // generate plaintexts for this election + val text = generate_plaintexts_for_eid(eid, numVotes, dto) + // write plaintexts for this election into output file + writePromise completeWith writer.write(text) + } + .recover + { + case error: Throwable => writePromise failure error + } + writePromise.future + }.map + { + _ => () + } + } + } + .recover + { + case error: Throwable => promise failure error + } + promise.future + } + + /** + * Reads the --election-ids file, which consists of an election id per line, + * and returns a set of election ids + */ + + def getElectionsSet() + : Future[scala.collection.immutable.Set[Long]] = + { + val promise = Promise[scala.collection.immutable.Set[Long]]() + Future + { + // convert string to path + val eids_path = Paths.get(election_ids_path) + // read file, split it into string lines + val electionsSet = + io.Source.fromFile(election_ids_path) + .getLines() + // convert each line to an integer representing the election id + .toList.map + { + strEid => strEid.toLong + } + .toSet + promise.success(electionsSet) + } + .recover + { + case error: Throwable => promise failure error + } + promise.future + } + +} + +/** + * Command for admin purposes + * + * use runMain commands.Command from console + * or + * activator "run-main commands.Command " + * from CLI + */ +object Console +{ + //implicit val ec = ExecutionContext.fromExecutor(new ForkJoinPool(100)) + //implicit val slickExecutionContext = Akka.system.dispatchers.lookup("play.akka.actor.slick-context") + + val intf : ConsoleInterface = new ConsoleImpl() + + def main(args: Array[String]) + : Unit = + { + if(0 == args.length) + { + intf.showHelp() + } else + { + intf.parse_args(args) + val command = args(0) + if ("gen_votes" == command) + { + intf.gen_votes() onComplete + { + case Success(value) => + println("gen_votes success") + System.exit(0) + case Failure(error) => + println("gen_votes error " + error) + System.exit(-1) + } + } + else if ("add_khmacs" == command) + { + intf.add_khmacs() onComplete + { + case Success(value) => + println("add_khmacs success") + System.exit(0) + case Failure(error) => + println("add_khmacs error " + error) + System.exit(-1) + } + } + else if ("gen_plaintexts" == command) + { + intf.gen_plaintexts() onComplete + { + case Success(value) => + println("gen_plaintexts success") + System.exit(0) + case Failure(error) => + println("gen_plaintexts error " + error) + System.exit(-1) + } + } + else + { + intf.showHelp() + System.exit(0) + } + } + () + } +} \ No newline at end of file diff --git a/app/commands/DemoVotes.scala b/app/commands/DemoVotes.scala index f0c45039..96e52d56 100644 --- a/app/commands/DemoVotes.scala +++ b/app/commands/DemoVotes.scala @@ -41,10 +41,9 @@ object DemoVotes { def main(args: Array[String]) : Unit = { val jsonPks = Json.parse(scala.io.Source.fromFile(args(0)).mkString) val pks = jsonPks.validate[Array[PublicKey]].get - val pk = pks(0) val jsonVotes = Json.parse(scala.io.Source.fromFile(args(1)).mkString) - val votes = jsonVotes.validate[Array[Long]].get + val votes = jsonVotes.validate[Array[Array[Long]]].get val toEncrypt = if(args.length == 3) { val extraSize = (args(2).toInt - votes.length).max(0) @@ -57,7 +56,7 @@ object DemoVotes { val histogram = toEncrypt.groupBy(l => l).map(t => (t._1, t._2.length)) System.err.println("DemoVotes: tally: " + histogram) - val jsonEncrypted = Json.toJson(toEncrypt.par.map(Crypto.encrypt(pk, _)).seq) + val jsonEncrypted = Json.toJson(toEncrypt.par.map(Crypto.encrypt(pks, _)).seq) println(jsonEncrypted) } } \ No newline at end of file diff --git a/app/controllers/BallotboxApi.scala b/app/controllers/BallotboxApi.scala index 2fb3a758..9900b90e 100644 --- a/app/controllers/BallotboxApi.scala +++ b/app/controllers/BallotboxApi.scala @@ -87,19 +87,16 @@ object BallotboxApi extends Controller with Response { val validated = vote.validate(pks, true, electionId, voterId) val result = DAL.votes.insertWithSession(validated) - val now = new java.util.Date().getTime / 1000 - val message = "$voterId:AuthEvent:$electionId:RegisterSuccessfulLogin:$now" - val timestamp: Long = System.currentTimeMillis / 1000 + val now: Long = System.currentTimeMillis / 1000 + val message = s"$voterId:AuthEvent:$electionId:RegisterSuccessfulLogin:$now" voteCallbackUrl.map { url => postVoteCallback( url .replace("${eid}", electionId+"") .replace("${uid}", voterId) , - message - .replace("$voterId", voterId) - .replace("$electionId", electionId+"") - .replace("$now", timestamp+"") + message, + vote.vote_hash ) } Ok(response(result)) @@ -201,7 +198,7 @@ object BallotboxApi extends Controller with Response { } } - private def postVoteCallback(url: String, message: String) = { + private def postVoteCallback(url: String, message: String, vote_hash: String) = { try { println(s"posting to $url") val hmac = Crypto.hmac(boothSecret, message) @@ -209,7 +206,8 @@ object BallotboxApi extends Controller with Response { val f = WS.url(url) .withHeaders( "Accept" -> "application/json", - "Authorization" -> khmac) + "Authorization" -> khmac, + "BallotTracker" -> vote_hash) .post(Results.EmptyContent()) .map { resp => if(resp.status != HTTP.ACCEPTED) { diff --git a/app/controllers/ElectionsApi.scala b/app/controllers/ElectionsApi.scala index cf61a38c..28592700 100644 --- a/app/controllers/ElectionsApi.scala +++ b/app/controllers/ElectionsApi.scala @@ -79,19 +79,21 @@ object ElectionsApi val slickExecutionContext = Akka.system.dispatchers.lookup("play.akka.actor.slick-context") val allowPartialTallies = Play.current.configuration.getBoolean("app.partial-tallies").getOrElse(false) val authorities = getAuthorityData + val download_tally_timeout = Play.current.configuration.getInt("app.download_tally_timeout").get + val download_tally_retries = Play.current.configuration.getInt("app.download_tally_retries").get /** inserts election into the db in the registered state */ - def register(id: Long) = HAction("", "AuthEvent", id, "edit").async(BodyParsers.parse.json) { request => + def register(id: Long) = HAction("", "AuthEvent", id, "edit|register").async(BodyParsers.parse.json) { request => registerElection(request, id) } /** updates an election's config */ - def update(id: Long) = HAction("", "AuthEvent", id, "edit").async(BodyParsers.parse.json) { request => + def update(id: Long) = HAction("", "AuthEvent", id, "edit|update").async(BodyParsers.parse.json) { request => updateElection(id, request) } - + /** updates an election's social share buttons config */ - def updateShare(id: Long) = HAction("", "AuthEvent", id, "edit").async(BodyParsers.parse.json) { request => + def updateShare(id: Long) = HAction("", "AuthEvent", id, "edit|update-share").async(BodyParsers.parse.json) { request => updateShareElection(id, request) } @@ -107,7 +109,7 @@ object ElectionsApi } /** Creates an election in eo */ - def create(id: Long) = HAction("", "AuthEvent", id, "edit").async { request => + def create(id: Long) = HAction("", "AuthEvent", id, "edit|create").async { request => getElection(id).flatMap(createElection).recover { @@ -255,7 +257,7 @@ object ElectionsApi } case _ => if( - (e.state == Elections.TALLY_OK || e.state == Elections.RESULTS_OK) || + (Elections.TALLY_OK == e.state || Elections.RESULTS_OK == e.state) || (e.virtual && e.state != Elections.RESULTS_PUB) ) { calcResults(id, config, validated.virtualSubelections.get).flatMap( r => updateResults(e, r) ) @@ -297,7 +299,8 @@ object ElectionsApi val future = getElection(id).flatMap { e => - if(e.state == Elections.TALLY_OK || e.state == Elections.RESULTS_OK) + if( Elections.TALLY_OK == e.state || + Elections.RESULTS_OK == e.state) { var electionConfigStr = Json.parse(e.configuration).as[JsObject] if (!electionConfigStr.as[JsObject].keys.contains("virtualSubelections")) @@ -715,9 +718,35 @@ object ElectionsApi Logger.info(s"downloading tally from $url") + // function to retry the http request a number of times + def retryWrapper( + wsRequest: WSRequestHolder, + f: Future[(WSResponseHeaders, Enumerator[Array[Byte]])], + times: Int) + : + Future[(WSResponseHeaders, Enumerator[Array[Byte]])] = + { + f.recoverWith { + case t : Throwable => + val promise = Promise[(WSResponseHeaders, Enumerator[Array[Byte]])]() + if (times > 0) + { + promise completeWith retryWrapper(wsRequest, wsRequest.getStream(), times - 1) + } + else + { + promise failure t + } + promise.future + }(slickExecutionContext) + } + // taken from https://www.playframework.com/documentation/2.3.x/ScalaWS + // configure http request + val wsRequest = WS.url(url).withRequestTimeout(download_tally_timeout) + // http request future (including retries) val futureResponse: Future[(WSResponseHeaders, Enumerator[Array[Byte]])] = - WS.url(url).getStream() + retryWrapper(wsRequest, wsRequest.getStream(), download_tally_retries) val downloadedFile: Future[Unit] = futureResponse.flatMap { case (headers, body) => diff --git a/app/models/Models.scala b/app/models/Models.scala index 67bebf76..7262b6ae 100644 --- a/app/models/Models.scala +++ b/app/models/Models.scala @@ -215,7 +215,11 @@ object Elections { } def updateState(id: Long, state: String)(implicit s: Session) = { - elections.filter(_.id === id).map(e => e.state).update(state) + state match { + case STARTED => elections.filter(_.id === id).map(e => (e.state, e.startDate)).update(state, new Timestamp(new Date().getTime)) + case STOPPED => elections.filter(_.id === id).map(e => (e.state, e.endDate)).update(state, new Timestamp(new Date().getTime)) + case _ => elections.filter(_.id === id).map(e => e.state).update(state) + } } def updateResults(id: Long, results: String)(implicit s: Session) = { @@ -420,7 +424,10 @@ case class QuestionExtra( shuffle_all_options: Option[Boolean], shuffle_category_list: Option[Array[String]], show_points: Option[Boolean], - default_selected_option_ids: Option[Array[Int]]) + default_selected_option_ids: Option[Array[Int]], + select_categories_1click: Option[Boolean], + answer_columns_size: Option[Int], + group_answer_pairs: Option[Boolean]) { def validate() = { @@ -443,6 +450,9 @@ case class QuestionExtra( assert(!recommended_preset__accept_text.isDefined || recommended_preset__accept_text.get.length <= LONG_STRING, "recommended_preset__accept_text too long") assert(!recommended_preset__deny_text.isDefined || recommended_preset__deny_text.get.length <= LONG_STRING, "recommended_preset__deny_text too long") + + assert(!answer_columns_size.isDefined || List(12,6,4,3).contains(answer_columns_size.get), "invalid answer_columns_size, can only be a string with 12,6,4,3") + assert(!(shuffle_all_options.isDefined && shuffle_category_list.isDefined && shuffle_all_options.get) || 0 == shuffle_category_list.get.size, "shuffle_all_options is true but shuffle_category_list is not empty") @@ -641,3 +651,17 @@ case class Popk(challenge: BigInt, commitment: BigInt, response: BigInt) /** data describing an authority, used in admin interface */ case class AuthData(name: Option[String], description: Option[String], url: Option[String], image: Option[String]) + +case class PlaintextAnswer(options: Array[Long] = Array[Long]()) +// id is the election ID +case class PlaintextBallot(id: Long = -1, answers: Array[PlaintextAnswer] = Array[PlaintextAnswer]()) + +/** + * This object contains the states required for reading a plaintext ballot + * It's used on Console.processPlaintextLine + */ +object PlaintextBallot +{ + val ID = 0 // reading election ID + val ANSWER = 1 // reading answers +} \ No newline at end of file diff --git a/app/utils/Crypto.scala b/app/utils/Crypto.scala index dcdd5600..ef9a35e7 100644 --- a/app/utils/Crypto.scala +++ b/app/utils/Crypto.scala @@ -103,9 +103,18 @@ import java.math.BigInteger } /** encode and then encrypt a plaintext */ - def encrypt(pk: PublicKey, value: Long) = { - val encoded = encode(pk, value) - encryptEncoded(pk, encoded) + def encrypt(pks: Array[PublicKey], values: Array[Long]) = { + val encoded = pks.view.zipWithIndex.map( t => + encode(t._1, values(t._2)) + ).toArray + encryptEncoded(pks, encoded) + } + + def encryptBig(pks: Array[PublicKey], values: Array[BigInt]) = { + val encoded = pks.view.zipWithIndex.map( t => + encodeBig(t._1, values(t._2)) + ).toArray + encryptEncoded(pks, encoded) } /** return a random bigint between 0 and max - 1 */ @@ -131,23 +140,42 @@ import java.math.BigInteger } } - /** encrypt an encoded plaintext */ - def encryptEncoded(pk: PublicKey, value: BigInt) = { - val r = randomBigInt(pk.q) - val alpha = pk.g.modPow(r, pk.p) - val beta = (value * (pk.y.modPow(r, pk.p))).mod(pk.p) - - // prove knowledge - val w = randomBigInt(pk.q) - - val commitment = pk.g.modPow(w, pk.p) - - val toHash = s"${alpha.toString}/${commitment.toString}" - val challenge = BigInt(sha256(toHash), 16) - - // compute response = w + randomness * challenge - val response = (w + (r * challenge)).mod(pk.q) + /** encode plaintext into subgroup defined by pk */ + def encodeBig(pk: PublicKey, value: BigInt) = { + val one = BigInt(1) + val m = value + one + val test = m.modPow(pk.q, pk.p) + if (test.equals(one)) { + m + } else { + -m.mod(pk.p) + } + } - EncryptedVote(Array(Choice(alpha, beta)), "now", Array(Popk(challenge, commitment, response))) + /** encrypt an encoded plaintext */ + def encryptEncoded(pks: Array[PublicKey], values: Array[BigInt]) = { + var choices = Array[Choice]() + var proofs = Array[Popk]() + for ( index <- 0 until pks.length ) { + val pk = pks(index) + val value = values(index) + val r = randomBigInt(pk.q) + val alpha = pk.g.modPow(r, pk.p) + val beta = (value * (pk.y.modPow(r, pk.p))).mod(pk.p) + + // prove knowledge + val w = randomBigInt(pk.q) + + val commitment = pk.g.modPow(w, pk.p) + + val toHash = s"${alpha.toString}/${commitment.toString}" + val challenge = BigInt(sha256(toHash), 16) + + // compute response = w + randomness * challenge + val response = (w + (r * challenge)).mod(pk.q) + choices :+= Choice(alpha, beta) + proofs :+= Popk(challenge, commitment, response) + } + EncryptedVote(choices, "now", proofs) } } \ No newline at end of file diff --git a/app/utils/JsonFormatters.scala b/app/utils/JsonFormatters.scala index d4b952f1..d9d04e79 100644 --- a/app/utils/JsonFormatters.scala +++ b/app/utils/JsonFormatters.scala @@ -22,6 +22,13 @@ import models._ import java.sql.Timestamp import scala.math.BigInt + +import play.api.libs._ +import json._ + +import shapeless.{ `::` => :#:, _ } +import poly._ + /** * Formatters for json parsing and writing * @@ -63,7 +70,77 @@ object JsonFormatters { implicit val urlF = Json.format[Url] implicit val answerF = Json.format[Answer] - implicit val questionExtraF = Json.format[QuestionExtra] + + ////////////////////////////////////////////////////////////////////////////// + // this is not pretty but at least it works + // it's necessary for case classes with >= 22 fields + // for this version of Play (2.3.6) + // it requires shapeless 2.0.0 (newer shapelessversions may not be compatible + // with this code) + // from https://gist.github.com/negator/5139ddb5f6d91cbe7b0c + // http://stackoverflow.com/questions/23571677/22-fields-limit-in-scala-2-11-play-framework-2-3-case-classes-and-functions + + + implicit val writesInstance: LabelledProductTypeClass[Writes] = new LabelledProductTypeClass[Writes] { + + def emptyProduct: Writes[HNil] = Writes(_ => Json.obj()) + + def product[F, T <: HList](name: String, FHead: Writes[F], FTail: Writes[T]) = Writes[F :#: T] { + case head :#: tail => + val h = FHead.writes(head) + val t = FTail.writes(tail) + + (h, t) match { + case (JsNull, t: JsObject) => t + case (h: JsValue, t: JsObject) => Json.obj(name -> h) ++ t + case _ => Json.obj() + } + } + def project[F, G](instance: => Writes[G], to: F => G, from: G => F) = Writes[F](f => instance.writes(to(f))) + } + object SWrites extends LabelledProductTypeClassCompanion[Writes] + + implicit val readsInstance: LabelledProductTypeClass[Reads] = new LabelledProductTypeClass[Reads] { + + def emptyProduct: Reads[HNil] = Reads(_ => JsSuccess(HNil)) + + def product[F, T <: HList](name: String, FHead: Reads[F], FTail: Reads[T]) = Reads[F :#: T] { + case obj @ JsObject(fields) => + for { + head <- FHead.reads(obj \ name) + tail <- FTail.reads(obj - name) + } yield head :: tail + + case _ => JsError("Json object required") + } + + def project[F, G](instance: => Reads[G], to: F => G, from: G => F) = Reads[F](instance.map(from).reads) + } + object SReads extends LabelledProductTypeClassCompanion[Reads] + + implicit val formatInstance: LabelledProductTypeClass[Format] = new LabelledProductTypeClass[Format] { + def emptyProduct: Format[HNil] = Format( + readsInstance.emptyProduct, + writesInstance.emptyProduct + ) + + def product[F, T <: HList](name: String, FHead: Format[F], FTail: Format[T]) = Format[F :#: T] ( + readsInstance.product[F, T](name, FHead, FTail), + writesInstance.product[F, T](name, FHead, FTail) + ) + + def project[F, G](instance: => Format[G], to: F => G, from: G => F) = Format[F]( + readsInstance.project(instance, to, from), + writesInstance.project(instance, to, from) + ) + } + object SFormats extends LabelledProductTypeClassCompanion[Format] + + implicit val questionExtraWrites : Writes[QuestionExtra] = SWrites.auto.derive[QuestionExtra] + implicit val questionExtraReads : Reads[QuestionExtra] = SReads.auto.derive[QuestionExtra] + ////////////////////////////////////////////////////////////////////////////// + + implicit val questionF = Json.format[Question] implicit val ShareTextItemF = Json.format[ShareTextItem] @@ -85,4 +162,12 @@ object JsonFormatters { implicit val tallyResponseF = Json.format[TallyResponse] implicit val authDataF = Json.format[AuthData] + + implicit val plaintextAnswerW : Writes[PlaintextAnswer] = + (JsPath \ "options").write[Array[Long]] contramap { (t: PlaintextAnswer) => t.options } + + implicit val plaintextAnswerR : Reads[PlaintextAnswer] = + (JsPath \ "options").read[Array[Long]] map (PlaintextAnswer.apply ) + + implicit val PlaintextBallotF = Json.format[PlaintextBallot] } diff --git a/build.sbt b/build.sbt index 16b9cc7b..cae36b66 100644 --- a/build.sbt +++ b/build.sbt @@ -17,6 +17,14 @@ name := """agora-elections""" version := "1.0-SNAPSHOT" +fork in run := true + +fork in Test := true + +javaOptions in Test += "-Dconfig.file=conf/application.test.conf" + +trapExit in run := false + lazy val root = (project in file(".")).enablePlugins(PlayScala) scalaVersion := "2.11.1" @@ -30,7 +38,8 @@ libraryDependencies ++= Seq( "org.bouncycastle" % "bcprov-jdk16" % "1.45", "com.googlecode.owasp-java-html-sanitizer" % "owasp-java-html-sanitizer" % "r239", "commons-validator" % "commons-validator" % "1.4.1", - "com.github.mumoshu" %% "play2-memcached-play23" % "0.7.0" + "com.github.mumoshu" %% "play2-memcached-play23" % "0.7.0", + "com.chuusai" %% "shapeless" % "2.0.0" ) resolvers += "Spy Repository" at "http://files.couchbase.com/maven2" // required to resolve `spymemcached`, the plugin's dependency. \ No newline at end of file diff --git a/conf/application.conf b/conf/application.conf index 8f989320..15352b6c 100644 --- a/conf/application.conf +++ b/conf/application.conf @@ -90,6 +90,30 @@ slick.default="models.*" application.global=global.Global +akka { + actor { + default-dispatcher { + # This will be used if you have set "executor = "fork-join-executor"" + fork-join-executor { + # Min number of threads to cap factor-based parallelism number to + parallelism-min = 8 + + # The parallelism factor is used to determine thread pool size using the + # following formula: ceil(available processors * factor). Resulting size + # is then bounded by the parallelism-min and parallelism-max values. + parallelism-factor = 3.0 + + # Max number of threads to cap factor-based parallelism number to + parallelism-max = 64 + + # Setting to "FIFO" to use queue like peeking mode which "poll" or "LIFO" to use stack + # like peeking mode which "pop". + task-peeking-mode = "FIFO" + } + } + } +} + # http://stackoverflow.com/questions/19780545/play-slick-with-securesocial-running-db-io-in-a-separate-thread-pool play { akka { diff --git a/conf/application.test.conf b/conf/application.test.conf new file mode 100644 index 00000000..40ae19bf --- /dev/null +++ b/conf/application.test.conf @@ -0,0 +1,3 @@ +ehcacheplugin=disabled +memcached.host="127.0.0.1:11211" +logger.memcached=WARN diff --git a/conf/evolutions/default/6.sql b/conf/evolutions/default/6.sql new file mode 100644 index 00000000..cd376277 --- /dev/null +++ b/conf/evolutions/default/6.sql @@ -0,0 +1,7 @@ +# --- !Ups + +CREATE INDEX "voter_id_created_desc_idx" ON vote (voter_id asc, created desc); + +# --- !Downs + +DROP INDEX "voter_id_created_desc_idx"; \ No newline at end of file diff --git a/test/ConsoleSpec.scala b/test/ConsoleSpec.scala new file mode 100644 index 00000000..667376aa --- /dev/null +++ b/test/ConsoleSpec.scala @@ -0,0 +1,523 @@ +/** + * This file is part of agora_elections. + * Copyright (C) 2017 Agora Voting SL + + * agora_elections is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License. + + * agora_elections is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + + * You should have received a copy of the GNU Affero General Public License + * along with agora_elections. If not, see . +**/ + +package test + +import org.specs2.mutable._ +import org.specs2.runner._ +import org.junit.runner._ + +import play.api.libs.json._ +import play.api.mvc.Action +import play.api.mvc.Results._ +import play.api.test._ +import commands._ +import models._ +import utils.JsonFormatters._ +import utils.Crypto +import scala.concurrent._ +import scala.concurrent.duration._ +import scala.util._ +import play.api.cache.Cache +import java.sql.Timestamp + +import play.api.libs.Files._ +import java.nio.file.{Paths, Files, Path} +import java.nio.file.StandardOpenOption._ +import java.nio.charset.StandardCharsets + + +@RunWith(classOf[JUnitRunner]) +class ConsoleSpec extends Specification with TestContexts +{ + val timeoutDuration = Duration(20, SECONDS) + val dtoStr = +"""{ + "id": 42, + "configuration": { + "id": 42, + "layout": "simple", + "director": "auth1", + "authorities": [ + "auth2" + ], + "title": "New election", + "description": "", + "questions": [ + { + "description": "", + "layout": "accordion", + "max": 2, + "min": 1, + "num_winners": 1, + "title": "New question 0", + "tally_type": "plurality-at-large", + "answer_total_votes_percentage": "over-total-valid-votes", + "answers": [ + { + "id": 0, + "category": "", + "details": "this is a long descriptionthis is a long descriptionthis is a long descriptionthis is a long description", + "sort_order": 0, + "urls": [], + "text": "ab" + }, + { + "id": 1, + "category": "", + "details": "this is a long descriptionthis is a long descriptionthis is a long descriptionthis is a long descriptionthis is a long description", + "sort_order": 1, + "urls": [], + "text": "cd" + } + ], + "extra_options": { + "shuffle_categories": true, + "shuffle_all_options": true, + "shuffle_category_list": [], + "show_points": false + } + } + ], + "start_date": "2015-01-27T16:00:00.001", + "end_date": "2015-01-27T16:00:00.001", + "presentation": { + "share_text": [ + { + "network": "Twitter", + "button_text": "", + "social_message": "I have just voted in election https://go.nvotes.com, you can too! #nvotes" + } + ], + "theme": "default", + "urls": [], + "theme_css": "" + }, + "real": false, + "extra_data": "{}", + "virtual": false, + "virtualSubelections": [], + "logo_url": "" + }, + "state": "created", + "startDate": "2015-01-27T16:00:00.001", + "endDate": "2015-01-27T16:00:00.001", + "pks": "[{\"q\":\"24792774508736884642868649594982829646677044143456685966902090450389126928108831401260556520412635107010557472033959413182721740344201744439332485685961403243832055703485006331622597516714353334475003356107214415133930521931501335636267863542365051534250347372371067531454567272385185891163945756520887249904654258635354225185183883072436706698802915430665330310171817147030511296815138402638418197652072758525915640803066679883309656829521003317945389314422254112846989412579196000319352105328237736727287933765675623872956765501985588170384171812463052893055840132089533980513123557770728491280124996262883108653723\",\"p\":\"49585549017473769285737299189965659293354088286913371933804180900778253856217662802521113040825270214021114944067918826365443480688403488878664971371922806487664111406970012663245195033428706668950006712214428830267861043863002671272535727084730103068500694744742135062909134544770371782327891513041774499809308517270708450370367766144873413397605830861330660620343634294061022593630276805276836395304145517051831281606133359766619313659042006635890778628844508225693978825158392000638704210656475473454575867531351247745913531003971176340768343624926105786111680264179067961026247115541456982560249992525766217307447\",\"y\":\"36546166940599313416168254217744697729911998485163232812421755063790894563031509201724580104715842554805784888514324341999107803878734424284146217463747343381092445557201708138031676925435834811853723665739792579746520854169833006814210270893803218238156148054483205249064331467974912676179467375074903261138777633846359449221132575280279543204461738096325062062234143265942024786615427875783393149444592255523696808235191395788053253235712555210828055008777307102133087648043115255896201502913954041965429673426222803318582002444673454964248205507662853386137487981094735018694462667314177271930377544606339760319819\",\"g\":\"27257469383433468307851821232336029008797963446516266868278476598991619799718416119050669032044861635977216445034054414149795443466616532657735624478207460577590891079795564114912418442396707864995938563067755479563850474870766067031326511471051504594777928264027177308453446787478587442663554203039337902473879502917292403539820877956251471612701203572143972352943753791062696757791667318486190154610777475721752749567975013100844032853600120195534259802017090281900264646220781224136443700521419393245058421718455034330177739612895494553069450438317893406027741045575821283411891535713793639123109933196544017309147\"}]", + "real": false, + "virtual": false, + "logo_url": "" +}""" + val dto : ElectionDTO = Json.parse(dtoStr).validate[ElectionDTO].get + + "parse_args" should + { + "read nothing with only one argument" in + { + val console = new ConsoleImpl() + val array: Array[String] = Array("console") + console.parse_args(array) must be equalTo(0) + } + + "read nothing with only two arguments" in + { + val console = new ConsoleImpl() + val array: Array[String] = Array("console", "whatever") + console.parse_args(array) must be equalTo(0) + } + + "throw IllegalArgumentException with wrong arguments" in + { + val console = new ConsoleImpl() + val array: Array[String] = Array("console", "--whatever", "value") + console.parse_args(array) must throwAn[java.lang.IllegalArgumentException] + } + + "throw IllegalArgumentException with invalid port" in + { + val console = new ConsoleImpl() + var array: Array[String] = Array("gen_votes", "--port", "value") + console.parse_args(array) must throwAn[java.lang.IllegalArgumentException] + array = Array("gen_votes", "--port", "0") + console.parse_args(array) must throwAn + array = Array("gen_votes", "--port", "65536") + console.parse_args(array) must throwAn[java.lang.IllegalArgumentException] + array = Array("gen_votes", "--port", "555555") + console.parse_args(array) must throwAn[java.lang.IllegalArgumentException] + } + + "read port with valid port" in + { + val console = new ConsoleImpl() + var array: Array[String] = Array("console", "--port", "40") + console.parse_args(array) must be equalTo(2) + console.port must be equalTo(40) + array = Array("gen_votes", "--port", "65535") + console.parse_args(array) must be equalTo(2) + console.port must be equalTo(65535) + } + } // parse_args + + "processPlaintextLine" should + { + "throw error on invalid line" in + { + val console = new ConsoleImpl() + console.processPlaintextLine("", 0) must throwAn[PlaintextError] + console.processPlaintextLine("d", 0) must throwAn[PlaintextError] + console.processPlaintextLine("1122", 0) must throwAn[PlaintextError] + } + + "process line with valid blank vote" in + { + val console = new ConsoleImpl() + var ballot = console.processPlaintextLine("14|", 0) + Json.toJson(ballot).toString must be equalTo("""{"id":14,"answers":[{"options":[]}]}""") + } + + "process line with valid vote" in + { + val console = new ConsoleImpl() + var ballot = console.processPlaintextLine("4888774|0,1,29||3", 0) + Json.toJson(ballot).toString must be equalTo("""{"id":4888774,"answers":[{"options":[0,1,29]},{"options":[]},{"options":[3]}]}""") + } + } // processPlaintextLine + + "get_khmac" should + { + "work" in + { + val console = new ConsoleImpl() + console.shared_secret = "" + val khmac = console.get_khmac("user_id", "obj_type", 11, "perms", Some(22)) + khmac must be equalTo(s"khmac:///sha-256;1ba07fd2e1becf9adfe74b4f6e0814fb4c9af3e0a920e718cec287857b480856/user_id:obj_type:11:perms:22") + } + } // get_khmac + + "get_election_info and get_election_info_all" should + { + val appWithRoutes = FakeApplication(withRoutes = + { + case ("GET", "/honolulu/api/election/24") => + Action + { + Ok("""{"date":"2017-04-03 09:21:04.923","payload":{"id":24,"configuration":{"id":24,"layout":"simple","director":"auth1","authorities":["auth2"],"title":"New election","description":"","questions":[{"description":"","layout":"accordion","max":1,"min":1,"num_winners":1,"title":"New question 0","tally_type":"plurality-at-large","answer_total_votes_percentage":"over-total-valid-votes","answers":[{"id":0,"category":"","details":"this is a long descriptionthis is a long descriptionthis is a long descriptionthis is a long description","sort_order":0,"urls":[],"text":"ab"},{"id":1,"category":"","details":"this is a long descriptionthis is a long descriptionthis is a long descriptionthis is a long descriptionthis is a long description","sort_order":1,"urls":[],"text":"cd"}],"extra_options":{"shuffle_categories":true,"shuffle_all_options":true,"shuffle_category_list":[],"show_points":false}}],"start_date":"2015-01-27T16:00:00.001","end_date":"2015-01-27T16:00:00.001","presentation":{"share_text":[{"network":"Twitter","button_text":"","social_message":"I have just voted in election https://go.nvotes.com, you can too! #nvotes"}],"theme":"default","urls":[],"theme_css":""},"real":false,"extra_data":"{}","virtual":false,"virtualSubelections":[],"logo_url":""},"state":"created","startDate":"2015-01-27T16:00:00.001","endDate":"2015-01-27T16:00:00.001","pks":"[{\"q\":\"24792774508736884642868649594982829646677044143456685966902090450389126928108831401260556520412635107010557472033959413182721740344201744439332485685961403243832055703485006331622597516714353334475003356107214415133930521931501335636267863542365051534250347372371067531454567272385185891163945756520887249904654258635354225185183883072436706698802915430665330310171817147030511296815138402638418197652072758525915640803066679883309656829521003317945389314422254112846989412579196000319352105328237736727287933765675623872956765501985588170384171812463052893055840132089533980513123557770728491280124996262883108653723\",\"p\":\"49585549017473769285737299189965659293354088286913371933804180900778253856217662802521113040825270214021114944067918826365443480688403488878664971371922806487664111406970012663245195033428706668950006712214428830267861043863002671272535727084730103068500694744742135062909134544770371782327891513041774499809308517270708450370367766144873413397605830861330660620343634294061022593630276805276836395304145517051831281606133359766619313659042006635890778628844508225693978825158392000638704210656475473454575867531351247745913531003971176340768343624926105786111680264179067961026247115541456982560249992525766217307447\",\"y\":\"36546166940599313416168254217744697729911998485163232812421755063790894563031509201724580104715842554805784888514324341999107803878734424284146217463747343381092445557201708138031676925435834811853723665739792579746520854169833006814210270893803218238156148054483205249064331467974912676179467375074903261138777633846359449221132575280279543204461738096325062062234143265942024786615427875783393149444592255523696808235191395788053253235712555210828055008777307102133087648043115255896201502913954041965429673426222803318582002444673454964248205507662853386137487981094735018694462667314177271930377544606339760319819\",\"g\":\"27257469383433468307851821232336029008797963446516266868278476598991619799718416119050669032044861635977216445034054414149795443466616532657735624478207460577590891079795564114912418442396707864995938563067755479563850474870766067031326511471051504594777928264027177308453446787478587442663554203039337902473879502917292403539820877956251471612701203572143972352943753791062696757791667318486190154610777475721752749567975013100844032853600120195534259802017090281900264646220781224136443700521419393245058421718455034330177739612895494553069450438317893406027741045575821283411891535713793639123109933196544017309147\"}]","real":false,"virtual":false,"logo_url":""}}""") + } + case ("GET", "/honolulu/api/election/25") => + Action + { + NotFound("ERROR") + } + }) + + "work (get_election_info)" in new WithServer(app = appWithRoutes, port = 5433) + { + val console = new ConsoleImpl() + console.http_type = "http" + console.host = "localhost" + console.port = 5433 + console.service_path = "honolulu/" + val dtoFuture24 : Future[String] = + console.get_election_info(24) map + { + dto => + Json.toJson(dto).toString + } + val dtoFuture25 : Future[String] = + console.get_election_info(25) map + { + dto => + Json.toJson(dto).toString + } + Await.ready(dtoFuture24, timeoutDuration).value.get must beSuccessfulTry + Await.ready(dtoFuture25, timeoutDuration).value.get must beFailedTry + } + + "work (get_election_info_all)" in new WithServer(app = appWithRoutes, port = 5433) + { + val console = new ConsoleImpl() + console.http_type = "http" + console.host = "localhost" + console.port = 5433 + console.service_path = "honolulu/" + val electionsSetFails = scala.collection.immutable.Set[Long](24, 25) + val electionsSetSuceeds = scala.collection.immutable.Set[Long](24) + + val dtoFutureFails = console.get_election_info_all(electionsSetFails) + val dtoFutureSuceeds = console.get_election_info_all(electionsSetSuceeds) + + Await.ready(dtoFutureFails, timeoutDuration).value.get must beFailedTry + Await.ready(dtoFutureSuceeds, timeoutDuration).value.get must beSuccessfulTry + } + } // get_election_info and get_election_info_all + + "dump_pks_elections/dump_pks" should + { + val appWithRoutes = FakeApplication(withRoutes = + { + case ("POST", "/honolulu/api/election/24/dump-pks") => + Action + { + Ok("whatever") + } + case ("POST", "/honolulu/api/election/25/dump-pks") => + Action + { + NotFound("ERROR") + } + }) + + "work (dump_pks)" in new WithServer(app = appWithRoutes, port = 5433) + { + val console = new ConsoleImpl() + console.http_type = "http" + console.host = "localhost" + console.port = 5433 + console.service_path = "honolulu/" + val dtoFuture24 = console.dump_pks(24) + val dtoFuture25 = console.dump_pks(25) + Await.ready(dtoFuture24, timeoutDuration).value.get must beSuccessfulTry + Await.ready(dtoFuture25, timeoutDuration).value.get must beFailedTry + } + + "work (dump_pks_elections)" in new WithServer(app = appWithRoutes, port = 5433) + { + val console = new ConsoleImpl() + console.http_type = "http" + console.host = "localhost" + console.port = 5433 + console.service_path = "honolulu/" + val electionsSetFails = scala.collection.immutable.Set[Long](24, 25) + val electionsSetSuceeds = scala.collection.immutable.Set[Long](24) + + val dtoFutureFails = console.dump_pks_elections(electionsSetFails) + val dtoFutureSuceeds = console.dump_pks_elections(electionsSetSuceeds) + + Await.ready(dtoFutureFails, timeoutDuration).value.get must beFailedTry + Await.ready(dtoFutureSuceeds, timeoutDuration).value.get must beSuccessfulTry + } + } // dump_pks_elections/dump_pks + + "encodePlaintext" should + { + "work" in + { + val console = new ConsoleImpl() + console.http_type = "http" + console.host = "localhost" + console.port = 5433 + console.service_path = "honolulu/" + val ballot: PlaintextBallot = + PlaintextBallot( + 42L, + Array( + PlaintextAnswer( + Array[Long](0L,1L)))) + console.encodePlaintext(ballot, dto).map{ num : BigInt => num.toLong.toString } must beEqualTo(Array("12")) + } + } // encodePlaintext + + "gen_rnd_str" should + { + "generate random strings" in + { + val console = new ConsoleImpl() + val rnd1_len2 = console.gen_rnd_str(2, "ab") + val rnd2_len2 = console.gen_rnd_str(2, "abcdefg1234567890") + val rnd_len200 = console.gen_rnd_str(200, "abcdefg1234567890") + + rnd1_len2.length must beEqualTo(2) + rnd2_len2.length must beEqualTo(2) + rnd_len200.length must beEqualTo(200) + rnd1_len2.matches("[ab]*") must beEqualTo(true) + rnd2_len2.matches("[abcdefg1234567890]*") must beEqualTo(true) + rnd_len200.matches("[abcdefg1234567890]*") must beEqualTo(true) + } + } // gen_rnd_str + + "generate_voterid" should + { + "generate random voter ids" in + { + val console = new ConsoleImpl() + console.voterid_len = 2 + val rnd_len2 = console.generate_voterid() + console.voterid_len = 200 + val rnd_len200 = console.generate_voterid() + + rnd_len2.length must beEqualTo(2) + rnd_len200.length must beEqualTo(200) + rnd_len2.matches(s"[${console.voterid_alphabet}]*") must beEqualTo(true) + rnd_len200.matches(s"[${console.voterid_alphabet}]*") must beEqualTo(true) + } + } // generate_voterid + + "generate_vote_line" should + { + val console = new ConsoleImpl() + val ballot: PlaintextBallot = + PlaintextBallot( + 42L, + Array( + PlaintextAnswer( + Array[Long](0L,1L)))) + val electionsInfoMap = scala.collection.mutable.HashMap[Long, ElectionDTO]( (42L -> dto) ) + val pksMap = console.get_pks_map(electionsInfoMap) + val pks = pksMap.get(42).get + + "work" in { + val encodedBallot = console.encodePlaintext( ballot, dto ) + val encryptedBallot = Crypto.encryptBig(pks, encodedBallot) + val line = console.generate_vote_line(42, encryptedBallot) + val match_regex = s"""42\\|[${console.voterid_alphabet}]+\\|\\{"vote":"\\{\\\\"choices\\\\":\\[\\{\\\\"alpha\\\\":\\\\"[0-9]+\\\\",\\\\"beta\\\\":\\\\"[0-9]+\\\\"\\}\\],\\\\"issue_date\\\\":\\\\"now\\\\",\\\\"proofs\\\\":\\[\\{\\\\"challenge\\\\":\\\\"[0-9]+\\\\",\\\\"commitment\\\\":\\\\"[0-9]+\\\\",\\\\"response\\\\":\\\\"[0-9]+\\\\"\\}\\]\\}","vote_hash":"[0-9a-f]+"\\}\n""" + line.matches(match_regex) must beEqualTo(true) + } + } // generate_vote_line + + "get_pks_map" should { + + "work" in { + val console = new ConsoleImpl() + val electionsInfoMap = scala.collection.mutable.HashMap[Long, ElectionDTO]( (42L -> dto) ) + val pksMap = console.get_pks_map(electionsInfoMap) + pksMap.get(45).isEmpty must beEqualTo(true) + pksMap.get(42).isEmpty must beEqualTo(false) + } + } // get_pks_map + + "generate_plaintexts_for_eid" should { + "work" in { + val console = new ConsoleImpl() + val plaintexts : String = console.generate_plaintexts_for_eid(42, 100, dto) + val regex = "[0-9]+(\\|([0-9]+,)*([0-9]+)?)*" + val linesArray = plaintexts.split("\n") + val matchesRegex = linesArray.map{ line => line.matches(regex)}.reduce(_ && _) + linesArray.size must beEqualTo(100) + matchesRegex must beEqualTo(true) + } + } + + "generate_map_num_votes_dto" should { + val dtoStr55 = +"""{ + "id": 55, + "configuration": { + "id": 55, + "layout": "simple", + "director": "auth1", + "authorities": [ + "auth2" + ], + "title": "New election", + "description": "", + "questions": [ + { + "description": "", + "layout": "accordion", + "max": 2, + "min": 1, + "num_winners": 1, + "title": "New question 0", + "tally_type": "plurality-at-large", + "answer_total_votes_percentage": "over-total-valid-votes", + "answers": [ + { + "id": 0, + "category": "", + "details": "this is a long descriptionthis is a long descriptionthis is a long descriptionthis is a long description", + "sort_order": 0, + "urls": [], + "text": "ab" + }, + { + "id": 1, + "category": "", + "details": "this is a long descriptionthis is a long descriptionthis is a long descriptionthis is a long descriptionthis is a long description", + "sort_order": 1, + "urls": [], + "text": "cd" + } + ], + "extra_options": { + "shuffle_categories": true, + "shuffle_all_options": true, + "shuffle_category_list": [], + "show_points": false + } + } + ], + "start_date": "2015-01-27T16:00:00.001", + "end_date": "2015-01-27T16:00:00.001", + "presentation": { + "share_text": [ + { + "network": "Twitter", + "button_text": "", + "social_message": "I have just voted in election https://go.nvotes.com, you can too! #nvotes" + } + ], + "theme": "default", + "urls": [], + "theme_css": "" + }, + "real": false, + "extra_data": "{}", + "virtual": false, + "virtualSubelections": [], + "logo_url": "" + }, + "state": "created", + "startDate": "2015-01-27T16:00:00.001", + "endDate": "2015-01-27T16:00:00.001", + "pks": "[{\"q\":\"24792774508736884642868649594982829646677044143456685966902090450389126928108831401260556520412635107010557472033959413182721740344201744439332485685961403243832055703485006331622597516714353334475003356107214415133930521931501335636267863542365051534250347372371067531454567272385185891163945756520887249904654258635354225185183883072436706698802915430665330310171817147030511296815138402638418197652072758525915640803066679883309656829521003317945389314422254112846989412579196000319352105328237736727287933765675623872956765501985588170384171812463052893055840132089533980513123557770728491280124996262883108653723\",\"p\":\"49585549017473769285737299189965659293354088286913371933804180900778253856217662802521113040825270214021114944067918826365443480688403488878664971371922806487664111406970012663245195033428706668950006712214428830267861043863002671272535727084730103068500694744742135062909134544770371782327891513041774499809308517270708450370367766144873413397605830861330660620343634294061022593630276805276836395304145517051831281606133359766619313659042006635890778628844508225693978825158392000638704210656475473454575867531351247745913531003971176340768343624926105786111680264179067961026247115541456982560249992525766217307447\",\"y\":\"36546166940599313416168254217744697729911998485163232812421755063790894563031509201724580104715842554805784888514324341999107803878734424284146217463747343381092445557201708138031676925435834811853723665739792579746520854169833006814210270893803218238156148054483205249064331467974912676179467375074903261138777633846359449221132575280279543204461738096325062062234143265942024786615427875783393149444592255523696808235191395788053253235712555210828055008777307102133087648043115255896201502913954041965429673426222803318582002444673454964248205507662853386137487981094735018694462667314177271930377544606339760319819\",\"g\":\"27257469383433468307851821232336029008797963446516266868278476598991619799718416119050669032044861635977216445034054414149795443466616532657735624478207460577590891079795564114912418442396707864995938563067755479563850474870766067031326511471051504594777928264027177308453446787478587442663554203039337902473879502917292403539820877956251471612701203572143972352943753791062696757791667318486190154610777475721752749567975013100844032853600120195534259802017090281900264646220781224136443700521419393245058421718455034330177739612895494553069450438317893406027741045575821283411891535713793639123109933196544017309147\"}]", + "real": false, + "virtual": false, + "logo_url": "" +}""" + val dto55 : ElectionDTO = Json.parse(dtoStr55).validate[ElectionDTO].get + "work" in { + val console = new ConsoleImpl() + val electionsInfoMap = + scala.collection.mutable.HashMap[Long, ElectionDTO]( + (42L -> dto), + (55L -> dto55)) + console.vote_count = 5 + val numVotesMap = console.generate_map_num_votes_dto(electionsInfoMap) + numVotesMap.get(42).get._1 must beEqualTo(3) + numVotesMap.get(55).get._1 must beEqualTo(2) + } + } // generate_map_num_votes_dto + + "getElectionsSet" should { + "work" in { + val tempFile = TemporaryFile(prefix = "election_ids", suffix = ".txt") + val filePath = tempFile.file.getAbsolutePath() + val eidsContent = "42\n55" + Files.write(Paths.get(filePath), eidsContent.getBytes(StandardCharsets.UTF_8), APPEND) + val console = new ConsoleImpl() + console.election_ids_path = filePath + val futureElectionSet = console.getElectionsSet() + val optionElectionSet = Await.ready(futureElectionSet, timeoutDuration).value + + optionElectionSet.get must beSuccessfulTry + val electionSet = optionElectionSet.get.get + electionSet.contains(42) must beEqualTo(true) + electionSet.contains(55) must beEqualTo(true) + electionSet.size must beEqualTo(2) + } // getElectionsSet + } + +} // ConsoleSpec