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