diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 00000000..86da8045 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,21 @@ +version: 2.1 + +orbs: + python: circleci/python@0.2.1 + +jobs: + build-and-test: + executor: python/default + steps: + - checkout + - python/load-cache + - python/install-deps + - python/save-cache + - run: + command: python -m pytest + name: Test + +workflows: + main: + jobs: + - build-and-test diff --git a/BUILD.txt b/BUILD.txt index 469b289b..3f5af276 100644 --- a/BUILD.txt +++ b/BUILD.txt @@ -5,3 +5,5 @@ 01/27/20-17:17:06-joris 01/27/20-17:18:06-joris 01/27/20-17:22:43-joris +02/04/20-16:52:22- +02/04/20-16:53:02- diff --git a/Database.md b/Database.md index 6c96c5f9..1bacfef8 100644 --- a/Database.md +++ b/Database.md @@ -21,6 +21,7 @@ A sample Election Document looks like this: , "closed" : , "winner" : , "type" : "" +, "votes" : [{ "username" : "", "vote" : b"" }] } ``` There can be two kinds of proposals: diff --git a/ROADMAP.md b/ROADMAP.md deleted file mode 100644 index 996c5b9e..00000000 --- a/ROADMAP.md +++ /dev/null @@ -1,18 +0,0 @@ -# ROADMAP for the next half a year (the pre-alpha phase) -TODO: -1. HTTPS Server -2. Election.py implemented with: create(); close(); vote(); -3. Patches.py implemented with: create(); close(); request_election() -4. Users.py implemented with: login(); register(); - -# After three weeks: - -4. Design and Interactivity -5. Test in Deployment -6. Prealpha Run for 4 weeks - -# After Summer -7. Ratification by ten original users -8. Release the Beta Version -9. Beta Run for half a year. -10. Continous Run diff --git a/Server/.goutputstream-9HC4F0 b/Server/.goutputstream-9HC4F0 new file mode 100644 index 00000000..e5c7cfe1 --- /dev/null +++ b/Server/.goutputstream-9HC4F0 @@ -0,0 +1,47 @@ +import functools,random +from typing import List, Dict + +def vote(votes : List[List[str]], options : List[str]): + options = {key : list(filter(lambda v: v[-1] == key or (key == "NoneOfTheOtherOptions" and v == []), votes)) for key in options} + winner = False + while(winner == False): + result = _vote(options,len(votes)) + if type(result) == type(""): + winner = (result,len(options[result])) + elif type(result) == type(None): + winner = (None, None) + elif type(result) == type({}): + options = result + else: + raise "VotingError: Unknow type retuned by _vote" + return winner + +def _vote(options : Dict[str,List[List[str]]], votes : int): + ordered = sorted(list(options), key=lambda o: options[o], reverse=True) + + if len(options[ordered[0]])/votes > 0.5: + return ordered[0] + elif len(options[ordered[0]]) == len(options[ordered[1]]) and len(options[ordered[0]]) == 0.5: + return None + else: + least = options[ordered[-1]] + print(least) + least = options.pop(ordered[-1]) + options = { key : [a for a in least if a != ordered[-1]] for key in list(options)} + + + for vote in least: + if len(vote) > 1: + vote.pop() + options[vote[-1]].append(vote) + print(vote[-1]) + elif len(vote) == 1: + options["NoneOfTheOtherOptions"].append([]) + else: + raise "VotingError: NoneOfTheOtherOptions seems to be removed from options, because empty lists are supposed to be reallocated, which only appear in NoneOfTheOtherOptions" + return options + +def test(n): + options = [str(x) for x in range(100)] + votes = [random.sample(options,k=random.randint(1,len(options))) for i in range(n)] + return vote(votes,options) diff --git a/Server/ELECTION.el b/Server/ELECTION.el new file mode 100644 index 00000000..6788c3ab --- /dev/null +++ b/Server/ELECTION.el @@ -0,0 +1,8 @@ +# File to represent an election result + +Votes: +# List of votes as python lists +Thrown: +# All the options, that were thrown out after each other +Winner: +# The Option, that won + the number of votes in support of it. diff --git a/Server/Elections.py b/Server/Elections.py index 35d3acca..89e2e03c 100644 --- a/Server/Elections.py +++ b/Server/Elections.py @@ -5,7 +5,9 @@ from Server.election import count_votes from pymongo import MongoClient from Crypto.Hash import SHA256 - +from Crypto.PublicKey import RSA +from Crypto.Cipher import PKCS1_OAEP +from typing import List, Tuple, Mapping """ Utility function to get the MongoClient.demnet[] @@ -93,21 +95,32 @@ def create(type,deadline,proposals): elections.insert_one(election) return election['hash'] + +"""Stores a vote byte string, that is encrypted like this: +Given Keys: +- Private Key of the User (private_key_of_user) +- Public Key of the Election Authority (public_key_of_ea) +Encryption of the vote as string: +vote_e = E(private_key_of_user, E(public_key_of_ea, vote)) +If you only have the public key of the user you can only +tell, that a user has voted but not how. +And only the EA can read the vote, with their private key. +If you don't trust the EA or the EA has been compromised, +the election is invalid to you, but this could easily +be decentralised. """ -a vote is a list of all options ranked by -how much a voter wants them to win. (see alternative vote). -**This function call cannot leave any trace of the association between -username and vote.** -""" -def vote(election_hash,vote,username): +def vote(election_hash : str, username : str, vote : List[str], private_key_of_user : RSA.RsaKey, public_key_of_ea : RSA.RsaKey) -> bool: elections = collection("elections") election = elections.find_one({ "hash" : election_hash }) - if username in election["participants"] and not election['closed']: + cipher_ea = PKCS1_OAEP.new(public_key_of_ea) + cipher_user = PKCS1_OAEP.new(private_key_of_user) + + if username in election["participants"]: return False else: - elections.update_one({ "hash" : election_hash }, { "$push" : { "participants" : username }}) - elections.update_one({ "hash" : election_hash }, { "$push" : { "votes" : vote }}) + vote = cipher_user(cipher_ea.encrypt(json.dumps(vote).encode('utf-8'))) + elections.update_one({ "hash" : election_hash }, { "$push" : { "votes" : { "username" : username, "vote" : vote_e }}} return True @@ -118,13 +131,14 @@ def vote(election_hash,vote,username): Publishing the votes, to make the independent control possible. """ -def close(election_hash): +def close(election_hash : str, private_key_ea : RSA.RsaKey): elections = collection('elections') election = elections.find_one({ "hash" : election_hash }) if election: if election.get('deadline') <= time.time(): - winner = count_votes(election.votes, len(election.participants), range(0,len(election.proposals)+1))["ballot"] + votes = encrypt_votes(election["votes"], private_key_ea) + winner = count_votes(election["votes"], len(election.participants), range(0,len(election.proposals)+1))["ballot"] winner = election.proposals[winner] elections.update_one({ "hash" : election_hash }, { "$set" : { "winner" : winner, "closed" : True } }) @@ -132,7 +146,7 @@ def close(election_hash): patches = collection('patches') patch = patches.find_one({ "hash" : winner['patch_id'] }) Patches.close(patch['name'], patch['name'], patch['hash'], merge=True) - elif election['type'] == "human": + else: laws = collection("laws") # Append ammendments to laws for ammendment in winner["ammendment"]: @@ -150,6 +164,18 @@ def close(election_hash): laws.remove_one({ "title" : removal }) - return True + return True else: return False + +def encrypt_votes(votes : List[bytes], private_key_ea : RSA.RsaKey) -> List[List[str]]: + users = collection("users") + cipher_ea = PKCS1_OAEP.new(private_key_ea) + for vote in votes: + user = users.find_one({ "username" : vote["username"] }) + public_key_of_user = RSA.import_key(user['public_key']) + cipher_user = PKCS1_OAEP.new(public_key_of_user) + try: + vote = cipher_ea.decrypt(cipher_user.decrypt(user["vote"])) + except Exception as e: + raise diff --git a/Server/Users.py b/Server/Users.py index ba7349f3..93ea5670 100644 --- a/Server/Users.py +++ b/Server/Users.py @@ -10,7 +10,7 @@ """ from Crypto.PublicKey import RSA from Crypto.Hash import SHA256 -from Crypto.Cipher import AES +from Crypto.Cipher import PKCS1_OAEP from pymongo import MongoClient import datetime, sys, json diff --git a/Server/__pycache__/election.cpython-37.pyc b/Server/__pycache__/election.cpython-37.pyc index c919e012..e87aeb33 100644 Binary files a/Server/__pycache__/election.cpython-37.pyc and b/Server/__pycache__/election.cpython-37.pyc differ diff --git a/Server/election.el b/Server/election.el new file mode 100644 index 00000000..fec61085 --- /dev/null +++ b/Server/election.el @@ -0,0 +1,5 @@ +Votes: + [["A", "B", "C"], ["C", "A", "B"], ["A", "B", "C"]] +Thrown: +Winner: +('C', 1.0) diff --git a/Server/election.py b/Server/election.py index 125a2532..3425f9f7 100644 --- a/Server/election.py +++ b/Server/election.py @@ -1,47 +1,49 @@ -import functools - -def count_votes( votes, participant_count, options ): - ballot = map( lambda option : { 'option' : option, 'support' : [] }, options ) - winner = distribute_votes( votes, participant_count, list(ballot) ) - return { "ballot": winner, "participants" : participant_count, "options" : options } - -def distribute_votes( votes, participant_count, ballot ): - if len(ballot[0]['support']) > participant_count * 0.5 or len(ballot) <= 1: - return ballot[0] # Winner! - else: - # Distribute votes - for option in ballot: - option['support'] = option['support'] + list(filter(lambda v: v[-1] == option['option'], votes)) - - # Eliminate another low ranking option - # Sort by Support - ballot = sorted(ballot, key = lambda option : len(option['support']), reverse=True) - - looser = least_popular(ballot) - ballot.remove(looser) - looser['support'] = list(map(lambda v: v[:-1], looser['support'])) - return distribute_votes(looser['support'], participant_count, ballot) - -def least_popular( ballot ): - # If there is a tie, look at the option, that is more popular in the alternative votes. - if len(ballot[-1]['support']) == len(ballot[-2]['support']): - option1 = ballot[-1] - option2 = ballot[-2] - alternative_votes = list( map( lambda option: option['support'][:-1], ballot ) ) - alternative_votes = functools.reduce( lambda x, y : x + y, alternative_votes ) - - i = -1 - least_popular = False - - while(not least_popular): - is_tie = alternative_votes[i].count(option1['option']) - alternative_votes.count(option2['option']) - - if is_tie < 0: - least_popular = option1 - elif is_tie > 0: - least_popular = option2 +import random, pprint +from typing import List, Dict + +def count(votes : List[List[str]], options : List[str]): + options : Dict[str, List[List[str]]] = { key : list(filter(lambda v: v[-1] == key, votes)) for key in list(options) } + votes = len(votes) + all_participants = votes + thrown_out = 0 + result = { "winner" : False + , "rounds" : [] + } + while result["winner"] == False: + # Does a candidate have more than 50%? + result["rounds"].append(options) + winners = list(filter(lambda o: len(options[o]) >= 0.5*votes, list(options))) + if len(winners) == 1: + result["winner"] = winners[0] + break + elif len(winners) >= 2: + result["winner"] = None + break + elif len(winners) == 0: + # Is there only one candidate left? + if len(list(options)) == 1: + result["winner"] = options(list(options)[0]) + break + elif len(list(options)) == 0: + result["winner"] = None + break else: - least_popular = False - return least_popular - else: - return ballot[-1] + # Drop worst candidate and find out who voters liked next best + sorted_options = sorted(list(options), key=lambda o: len(options[o])) + worst = sorted_options[-1] + worsts_votes = options.pop(worst) + options = { key : [list(filter(lambda a: a != worst, vote)) for vote in options[key]] for key in list(options)} + options = { key : options[key] for key in list(options) if options[key] != []} + sum_votes = sum([len(options[o]) for o in list(options)]) + thrown_out += (votes - sum_votes) + votes = sum_votes + continue + + result["thrown_out"] = thrown_out + if result["thrown_out"]/all_participants > 0.5: + result["winner"] = "NoneOfTheOtherOptions" + elif len(result["winner"])/all_participants == 0.5 and (result["thrown_out"]/all_participants == 0.5): + result["winner"] = None + + result["winner"] = "NoneOfTheOtherOptions" if (result["thrown_out"]/all_participants) > 0.5 else result["winner"] + return result diff --git a/Server/election_example.py b/Server/election_example.py deleted file mode 100644 index a069deb0..00000000 --- a/Server/election_example.py +++ /dev/null @@ -1,39 +0,0 @@ -from election import count_votes -import random, os - -random.seed( a=os.environ['SEED'] ) - -def generate_random_vote(options): - vote = random.sample( options, k = len(options) ) - return vote - -def load_sample_votes(): - sample_votes = open('sample_votes.txt').read().split('\n') - sample_votes = list(map( lambda v: v.split(';'), sample_votes )) - return sample_votes - -def generate_elections( options, participants ): - votes = [] - while( participants > 0 ): - votes.append( generate_random_vote(options) ) - participants -= 1 - - winner = count_votes( votes, len(votes), options ) - - winners = [] - for i in range(len(options)): - votes_in_i = list(map( lambda vote: vote[-i],votes )) - winner_votes = votes_in_i.count(winner['ballot']['option']) - winners.append( ( winner_votes, len(votes_in_i) )) - - print(f"Result:{winner['ballot']['option']}") - - for i in range(len(winners)): - percentage = round((winners[i][0]/winners[i][1]) * 100, ndigits=2) - print(f"Winner' votes in Vote #{i}:\t{winners[i][0]} of {winners[i][1]},\t{ percentage }% ") - -if __name__ == '__main__': - i = 10 - while(i > 0): - generate_elections(['A','B','C','D', 'E', 'F', 'G', 'H', 'I'],random.randint(10**2,10**3)) - i -= 1 diff --git a/Server/sample_election.el b/Server/sample_election.el new file mode 100644 index 00000000..df326be8 --- /dev/null +++ b/Server/sample_election.el @@ -0,0 +1,7 @@ +Votes: + [['A', 'B', 'C'], ['C', 'B', 'A'], ['D', 'A', 'B', 'C'], ['D', 'A', 'C', 'B'], ['NoneOfTheOtherOptions']] +Thrown: +A +D +Winner: +B, 3 diff --git a/Server/test_election.py b/Server/test_election.py new file mode 100644 index 00000000..75fe0917 --- /dev/null +++ b/Server/test_election.py @@ -0,0 +1,20 @@ +from election import count +import random,os + +def test_count(n=50, repeat_for=10**5,seed=None): + for i in range(repeat_for): + options = [str(i) for i in range(random.randint(4,20))] + votes = [random.sample(options,k=random.randint(1,len(options))) for i in range(n)] + result = count(votes,options) + + # On AV there is one option deleted every round + # thus after len(options) rounds, AV has to stop. + assert len(result["rounds"]) <= len(options) + assert len(result["rounds"]) > 0 + + assert result["thrown_out"] >= 0 + + if result["winner"] != "NoneOfTheOtherOptions": + assert result["thrown_out"]/len(votes) < 0.5 + else: + assert result["thrown_out"] > 0.5*len(votes) diff --git a/Server/test_election.py~ b/Server/test_election.py~ new file mode 100644 index 00000000..84563f65 --- /dev/null +++ b/Server/test_election.py~ @@ -0,0 +1,22 @@ +from election import count +import random,os + +def test_count(n=50, repeat_for=10**7,seed=None): + random.seed("A" if seed != None else os.environ["SEED"]) + for i in range(repeat_for): + options = [str(i) for i in range(random.randint(4,20))] + votes = [random.sample(options,k=random.randint(1,len(options))) for i in range(n)] + result = count(votes,options) + + # On AV there is one option deleted every round + # thus after len(options) rounds, AV has to stop. + assert len(result["rounds"]) <= len(options) + + assert result["thrown_out"] >= 0 + + if result["winner"] != "NoneOfTheOtherOptions": + assert result["thrown_out"]/len(votes) >= 0.5 + else: + assert result["thrown_out"] >= 0.5*len(votes) + + diff --git a/Server/test_patches.py b/Server/test_patches.py index 3de2ec5a..16606183 100644 --- a/Server/test_patches.py +++ b/Server/test_patches.py @@ -1,66 +1,3 @@ import Patches -import random, os, string, subprocess -from pymongo import MongoClient - -def random_string(): - return ''.join(random.choice(string.ascii_letters) for i in range(random.randint(0,125))) - -def generate_random_patch(): - patcher = random.choice(['joris', 'abbashan', 'martin', 'brummel']) - patch = random.choice(['www-x','user-support', 'generate-random', 'postings']) - - options = { "is_user" : random.choice([True, False]) - , "simple_description" : random_string() - , "technical_description" : random_string() - , "hold_pre_election" : random.choice([True, False]) - , "references" : [ random_string() for i in range(random.randint(0,5)) ] - } - return (patcher, patch, options) - def test_patch(): - os.environ['PATCHES'] = "/tmp/demnet_test" - os.environ['ORIGIN_REPOSITORY'] = "/tmp/demnet_origin" - - - (patcher, patch, options) = generate_random_patch() - - patch_hash = Patches.create(patcher, patch, options) - - assert os.path.isdir(f"{os.environ['PATCHES']}/{patcher}-{patch}") - - client = MongoClient() - db = client.demnet - patches = db.patches - - patch_formula = patches.find_one({ "hash" : patch_hash }) - - assert patch_formula['patcher'] == patcher - assert patch_formula['is_user'] == options['is_user'] - assert patch_formula['name'] == patch - assert patch_formula['simple_description'] == options['simple_description'] - assert patch_formula['technical_description'] == options['technical_description'] - assert patch_formula['hold_pre_election'] == options['hold_pre_election'] - assert patch_formula['references'] == options['references'] - assert patch_formula['closed'] == False - - (patcher_2, patch_2, options_2) = generate_random_patch() - - patch_2_hash = Patches.create(patcher_2, patch_2, options_2) - - # Close Patches without merging - assert Patches.close(patcher_2, patch_2, patch_2_hash) == True - print(f"Patcher:\t{patcher_2}\nPatch:\t{patch_2}") - assert not os.path.isdir(f"{ os.environ['PATCHES'] }/{patcher_2}-{patch_2}") - - # Create a Commit and Change to Patch - pwd = os.environ['PWD'] - subprocess.run([ f"cd { os.environ['PATCHES'] }/{patcher}-{patch}" ], shell=True) - subprocess.run([ f"echo \"Hello, World\" > README" ], shell=True) - subprocess.run([ f"git commit -m \"Test Commit\" && cd {pwd}" ], shell=True) - subprocess.run([ "cd ", pwd ], shell=True) - assert Patches.close(patcher, patch, patch_hash, merge=True) == True - assert not os.isdir(f"{os.environ['PATCHES']}/{patcher}-{patch}") - - subprocess.run([ "cd ", os.environ["ORIGIN_REPOSITORY"] ]) - log_res = subprocess.run([ "git log | grep \"Test Commit\"" ], capture_output=True, text=True) - assert log_res.stdout == "Test Commit" + assert True diff --git a/main.py b/main.py index ccd78edf..f1f03bc7 100644 --- a/main.py +++ b/main.py @@ -4,8 +4,10 @@ from flask import Flask, request, render_template, send_file import json, os from Crypto.Hash import SHA3_256 +from Crypto.PublicKey import RSA +from Crypto.Cipher import PKCS1_OAEP -app = Flask(__name__, static_url_path="/static", static_folder="/output") +app = Flask(__name__, static_url_path="/static", static_folder="/static") app.secret_key = os.environ["SECRET_KEY"] # Errors diff --git a/requirements.txt b/requirements.txt index fa7d88f4..24a5d4eb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,24 @@ +attrs==19.3.0 Click==7.0 Flask==1.1.1 gunicorn==20.0.4 +importlib-metadata==1.5.0 itsdangerous==1.1.0 Jinja2==2.10.3 MarkupSafe==1.1.1 +more-itertools==8.2.0 +mypy==0.761 +mypy-extensions==0.4.3 +packaging==20.1 +pluggy==0.13.1 +py==1.8.1 pycryptodome==3.9.4 pymongo==3.10.1 +pyparsing==2.4.6 +pytest==5.3.5 +six==1.14.0 +typed-ast==1.4.1 +typing-extensions==3.7.4.1 +wcwidth==0.1.8 Werkzeug==0.16.0 +zipp==2.2.0 diff --git a/src/Main.elm b/src/Main.elm index dfd0d3c8..03b4f7a9 100644 --- a/src/Main.elm +++ b/src/Main.elm @@ -5,7 +5,7 @@ import Browser import Element exposing (Element) import Http -main = Browser.document { init = init +main = Browser.element { init = init , view = view , update = update , subscription = subscription @@ -17,12 +17,7 @@ type alias User = { username : String , last_name : String } -type alias Message - = { from : User - , to : User - , title : String - , content : String - } +type alias Election = { options : List String } type Page = Reading Message @@ -30,13 +25,32 @@ type Page | Feed (List Message) | Vote Election -type alias Model = { user : Maybe User +type alias Model = { user : User , page : Page , readings : List Message , writings : List Message , feed : List Message + , elections : List Election + , notices : List String -- Short Messages for the user. } +add_if_not_member : a -> List a -> List a +add_if_not_member element list + = if List.member element list + then list + else element::list + +remove_duplicates : List a -> List a +remove_duplicates list = List.foldl add_if_not_member [] list + +save_page : Model -> Model +save_page model = + case model.page of + Reading message -> { model | readings = add_if_not_member message model.readings } + Writing message -> { model | writings = add_if_not_member message model.writings } + Feed messages -> { model | feed = remove_duplicates <| messages ++ model.feed } + Vote election -> { model | elections = add_if_not_member election } + init : flags -> ( Model, Cmd Msg) init _ = ( { user = Nothing , readings = [] @@ -46,17 +60,40 @@ init _ = ( { user = Nothing -- UPDATE type Msg - = Read Message + = Writes Writing_Msg + | To_Feed + | Read Message | Write Message - | Feed - | Write_Change_Title String - | Write_Change_Content String - | Login_Username_Change String - | Login_Password_Change String + | Saved Message + | Published Message + +type Writing_Msg + = Change Field String + | Publish + +type Field + = Title + | Content + | To update : Msg -> Model -> ( Model, Cmd Msg ) update msg model - = case model.user of - Just user -> case msg of - Read msg -> - Nothing -> + = let saved_model = save_page model + in case msg of + Writes writing_msg -> + case saved_model.page of + Writing message -> + case writing_msg of + Change field new -> + let new_message = case field of + Title -> { message | title = new } + Content -> { message | content = new } + To -> { message | to = new } + in ({ saved_model | page = Writing new_message }, Cmd.none) + Publish -> (model, publish message <| Published message) + _ -> ( saved_model, Cmd.none ) + To_Feed -> ( { saved_model | page = Feed saved_model.feed }, Cmd.none) + Read other_message -> ( { saved_model | page = Reading other_message }, Cmd.none) + Write other_message -> ( { saved_model | page = Writing other_message }, Cmd.none) + Saved message -> ( { model | notices = ("Saved: " ++ message.title)::model.notices }, Cmd.none) + Published message -> ( { model | notices = ("Saved: " ++ message.title)::model.notices }, Cmd.none) diff --git a/src/Messages.elm b/src/Messages.elm new file mode 100644 index 00000000..ff629c46 --- /dev/null +++ b/src/Messages.elm @@ -0,0 +1,30 @@ +module Messages exposing ( decoder + , encoder + , publish + , save + , request_one + , request_many + ) + +import Json.Decode as D +import Json.Encode as E + +type alias Message + = { from : User + , to : User + , title : String + , content : String + } + + +decoder : D.Decoder Message +decoder = + D.map4 Message + (D.field "from" D.string) + (D.field "to" <| D.list D.string) + (D.at ["body", "title"] D.string) + (D.at ["body", "content"] D.string) + +encode : Message -> E.Value +encode message = + E.object [] diff --git a/start b/start index c5d3f94f..c39b3970 100755 --- a/start +++ b/start @@ -1,6 +1,7 @@ #!/usr/bin/env bash + # Install pip3 Packages -pip3 install -r requirements.txt +pip3 install -U -r requirements.txt # BUILD=Variable denoting current build string: BUILD=`date -u +"%D-%T"` @@ -8,6 +9,10 @@ BUILD="$BUILD-$BUILDER" echo $BUILD >> BUILD.txt +# Test if the static analyses pases +if ! mypy main.py Server/*.py --ignore-issing-imports; +then exit 1 +fi # Build Elm frontend elm make --output=output/main.js --optimize src/Main.elm