├── .dockerignore ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ └── enhancement.yml ├── pull_request_template.md └── workflows │ ├── azure_deploy.yml │ ├── pull_request.yml │ └── release.yml ├── .gitignore ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── SECURITY.md ├── app ├── __init__.py ├── api │ ├── __init__.py │ ├── v1 │ │ ├── __init__.py │ │ ├── auth │ │ │ ├── __init__.py │ │ │ ├── auth.py │ │ │ ├── routes.py │ │ │ └── user.py │ │ ├── common │ │ │ ├── __init__.py │ │ │ ├── ping.py │ │ │ ├── routes.py │ │ │ └── type_mapper.py │ │ ├── guardian │ │ │ ├── __init__.py │ │ │ ├── ballot.py │ │ │ ├── guardian.py │ │ │ ├── routes.py │ │ │ └── tally_decrypt.py │ │ ├── mediator │ │ │ ├── __init__.py │ │ │ ├── ballot.py │ │ │ ├── decrypt.py │ │ │ ├── election.py │ │ │ ├── encrypt.py │ │ │ ├── key_admin.py │ │ │ ├── key_ceremony.py │ │ │ ├── key_guardian.py │ │ │ ├── manifest.py │ │ │ ├── routes.py │ │ │ ├── tally.py │ │ │ └── tally_decrypt.py │ │ ├── models │ │ │ ├── __init__.py │ │ │ ├── auth.py │ │ │ ├── ballot.py │ │ │ ├── base.py │ │ │ ├── decrypt.py │ │ │ ├── election.py │ │ │ ├── encrypt.py │ │ │ ├── guardian.py │ │ │ ├── key_ceremony.py │ │ │ ├── key_guardian.py │ │ │ ├── manifest.py │ │ │ ├── tally.py │ │ │ ├── tally_decrypt.py │ │ │ └── user.py │ │ ├── routes.py │ │ └── tags.py │ └── v1_1 │ │ ├── common │ │ ├── __init__.py │ │ ├── ping.py │ │ └── routes.py │ │ ├── guardian │ │ ├── __init__.py │ │ ├── ping.py │ │ └── routes.py │ │ ├── mediator │ │ ├── __init__.py │ │ ├── election.py │ │ └── routes.py │ │ ├── models │ │ ├── __init__.py │ │ ├── base.py │ │ └── election.py │ │ ├── routes.py │ │ └── tags.py ├── core │ ├── __init__.py │ ├── auth.py │ ├── ballot.py │ ├── client.py │ ├── election.py │ ├── guardian.py │ ├── key_ceremony.py │ ├── key_guardian.py │ ├── manifest.py │ ├── queue.py │ ├── repository.py │ ├── scheduler.py │ ├── schema.py │ ├── settings.py │ ├── tally.py │ ├── tally_decrypt.py │ └── user.py ├── data │ ├── manifest.json │ ├── old-ballot.json │ ├── plaintext_ballot_ballot-1663ab54-e95f-11eb-bd0c-acde48001122.json │ ├── plaintext_ballot_ballot-1663bbee-e95f-11eb-bd0c-acde48001122.json │ ├── plaintext_ballot_ballot-1663cdc8-e95f-11eb-bd0c-acde48001122.json │ ├── plaintext_ballot_ballot-1663d854-e95f-11eb-bd0c-acde48001122.json │ └── plaintext_ballot_ballot-1663e3e4-e95f-11eb-bd0c-acde48001122.json └── main.py ├── docker-compose.azure.yml ├── docker-compose.dev.yml ├── docker-compose.support.yml ├── docker-compose.yml ├── docs └── index.md ├── images ├── electionguard-banner.svg ├── electionguard-logo-large.png └── electionguard-logo-small.png ├── mkdocs.yml ├── mongo-init.js ├── poetry.lock ├── pyproject.toml └── tests ├── __init__.py ├── integration ├── __init__.py ├── api_utils.py ├── conftest.py ├── data │ ├── ballot-encrypted-simple.json │ ├── ballot-plaintext-simple.json │ ├── ballot.json │ ├── election_description.json │ └── test_data.py ├── guardian_api.py ├── mediator_api.py └── test_end_to_end_election.py ├── manual.md └── postman ├── .dockerignore ├── Dockerfile ├── ElectionGuard API Tests.postman_collection.json ├── ElectionGuard Web Api.postman_collection.json ├── Local Environment.postman_environment.json └── docker-compose.yml /.dockerignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.pyc 3 | *.pyo 4 | *.pyd 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: 🐞 Bug 2 | description: Submit a bug if something isn't working as expected. 3 | title: "🐞 " 4 | labels: [bug, triage] 5 | body: 6 | - type: checkboxes 7 | attributes: 8 | label: Is there an existing issue for this? 9 | description: Please search to see if an issue already exists for the bug you encountered. 10 | options: 11 | - label: I have searched the existing issues 12 | required: true 13 | - type: textarea 14 | attributes: 15 | label: Current Behavior 16 | description: A concise description of what you're experiencing. 17 | validations: 18 | required: true 19 | - type: textarea 20 | attributes: 21 | label: Expected Behavior 22 | description: A concise description of what you expected to happen. 23 | validations: 24 | required: false 25 | - type: textarea 26 | attributes: 27 | label: Steps To Reproduce 28 | description: Steps to reproduce the behavior. 29 | placeholder: | 30 | 1. In this environment... 31 | 2. With this config... 32 | 3. Run '...' 33 | 4. See error... 34 | validations: 35 | required: false 36 | - type: textarea 37 | attributes: 38 | label: Environment 39 | description: | 40 | examples: 41 | - **OS**: Ubuntu 20.04 42 | value: | 43 | - OS: 44 | render: markdown 45 | validations: 46 | required: false 47 | - type: textarea 48 | attributes: 49 | label: Anything else? 50 | description: | 51 | Links? References? Screenshots? Possible Solution? Anything that will give us more context about the issue you are encountering! 52 | 53 | Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. 54 | validations: 55 | required: false 56 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Discussions 4 | url: https://github.com/microsoft/electionguard/discussions 5 | about: Discuss suggestions and new enhancements here. 6 | - name: ElectionGuard Info 7 | url: https://https://www.electionguard.vote/ 8 | about: Learn more about ElectionGuard or email the team. 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/enhancement.yml: -------------------------------------------------------------------------------- 1 | name: ✨ Enhancement 2 | description: Suggest an enhancement or an improvement. 3 | title: "✨ <title>" 4 | labels: [enhancement, triage] 5 | body: 6 | - type: checkboxes 7 | attributes: 8 | label: Is there an existing issue for this? 9 | description: Please search to see if an issue already exists for the suggestion. 10 | options: 11 | - label: I have searched the existing issues 12 | required: true 13 | - type: textarea 14 | attributes: 15 | label: Suggestion 16 | description: Tell us how we could improve ElectionGuard. 17 | validations: 18 | required: true 19 | - type: textarea 20 | attributes: 21 | label: Possible Implementation 22 | description: Not obligatory, but ideas as to the implementation of the suggestion. 23 | validations: 24 | required: false 25 | - type: textarea 26 | attributes: 27 | label: Anything else? 28 | description: | 29 | What are you trying to accomplish? 30 | 31 | Links? References? Anything that will give us more context about the suggestion! 32 | 33 | Tip: You can attach images by clicking this area to highlight it and then dragging files in. 34 | validations: 35 | required: false 36 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | [//]: # (🚨 Please review the CONTRIBUTING.md in this repository. 💔Thank you!) 2 | 3 | ### Issue 4 | *Link your PR to an issue* 5 | 6 | Fixes #___ 7 | 8 | ### Description 9 | *Please describe your pull request.* 10 | 11 | ### Testing 12 | *Describe the best way to test or validate your PR.* 13 | -------------------------------------------------------------------------------- /.github/workflows/azure_deploy.yml: -------------------------------------------------------------------------------- 1 | name: Azure_Deploy_Workflow 2 | on: 3 | push: 4 | branches: 5 | - main 6 | milestone: 7 | types: [closed] 8 | repository_dispatch: 9 | types: [milestone_closed] 10 | 11 | jobs: 12 | build-and-deploy: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: "Checkout GitHub Action" 16 | uses: actions/checkout@main 17 | 18 | - name: "Login via Azure CLI" 19 | uses: azure/login@v1 20 | with: 21 | creds: ${{ secrets.AZURE_CREDENTIALS }} 22 | 23 | - name: "Build and push image" 24 | uses: azure/docker-login@v1 25 | with: 26 | login-server: ${{ secrets.REGISTRY_LOGIN_SERVER }} 27 | username: ${{ secrets.REGISTRY_USERNAME }} 28 | password: ${{ secrets.REGISTRY_PASSWORD }} 29 | - run: | 30 | docker build . -t ${{ secrets.DEPLOY_REGISTRY }}.azurecr.io/electionguard-api-python:${{ github.sha }} -t ${{ secrets.DEPLOY_REGISTRY }}.azurecr.io/electionguard-api-python:latest 31 | docker push ${{ secrets.DEPLOY_REGISTRY }}.azurecr.io/electionguard-api-python --all-tags 32 | 33 | - name: "Deploy All to Azure Container Instances" 34 | uses: "pierreVH2/azure-containergroup-deploy@master" 35 | with: 36 | resource-group: ${{ secrets.RESOURCE_GROUP }} 37 | group-name: electionguard-demo 38 | registry-login-server: ${{ secrets.REGISTRY_LOGIN_SERVER }} 39 | registry-username: ${{ secrets.REGISTRY_USERNAME }} 40 | registry-password: ${{ secrets.REGISTRY_PASSWORD }} 41 | location: "east us" 42 | containers: '[ 43 | { 44 | "name": "electionguard-ui-app", 45 | "image": "${{ secrets.DEPLOY_REGISTRY }}.azurecr.io/electionguard-ui:latest", 46 | "command": "serve -l 3000 -s build", 47 | "cpu": 0.5, 48 | "memory": 1.5, 49 | "ports": "3000" 50 | }, 51 | { 52 | "name": "electionguard-ui-storybook", 53 | "image": "${{ secrets.DEPLOY_REGISTRY }}.azurecr.io/electionguard-ui:latest", 54 | "command": "serve -l 6006", 55 | "cpu": 1, 56 | "memory": 1.5, 57 | "ports": "6006" 58 | }, 59 | { 60 | "name": "electionguard-api-python-mediator", 61 | "image": "${{ secrets.DEPLOY_REGISTRY }}.azurecr.io/electionguard-api-python:latest", 62 | "environmentVariables": "API_MODE=\"mediator\" QUEUE_MODE=\"remote\" STORAGE_MODE=\"mongo\" PROJECT_NAME=\"ElectionGuard Mediator API\" PORT=8000 MESSAGEQUEUE_URI=\"amqp://guest:guest@electionguard-message-queue:5672\" MONGODB_URI=${{ secrets.COSMOSDB_URI }}", 63 | "cpu": 1, 64 | "memory": 1.5, 65 | "ports": "8000" 66 | }, 67 | { 68 | "name": "electionguard-api-python-guardian", 69 | "image": "${{ secrets.DEPLOY_REGISTRY }}.azurecr.io/electionguard-api-python:latest", 70 | "environmentVariables": "API_MODE=\"guardian\" QUEUE_MODE=\"remote\" STORAGE_MODE=\"mongo\" PROJECT_NAME=\"ElectionGuard Guardian API\" PORT=8001 MESSAGEQUEUE_URI=\"amqp://guest:guest@electionguard-message-queue:5672\" MONGODB_URI=${{ secrets.COSMOSDB_URI }}", 71 | "cpu": 1, 72 | "memory": 1.5, 73 | "ports": "8001" 74 | }, 75 | { 76 | "name": "electionguard-message-queue", 77 | "image": "rabbitmq:3.8.16-management-alpine", 78 | "cpu": 0.5, 79 | "memory": 2, 80 | "ports": "5672 15672" 81 | }]' 82 | -------------------------------------------------------------------------------- /.github/workflows/pull_request.yml: -------------------------------------------------------------------------------- 1 | name: Validate Pull Request 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | repository_dispatch: 8 | types: [pull_request] 9 | 10 | env: 11 | PYTHON_VERSION: 3.9 12 | 13 | jobs: 14 | code_analysis: 15 | name: Code Analysis 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout Code 19 | uses: actions/checkout@v2 20 | - name: Set up Python ${{ env.PYTHON_VERSION }} 21 | uses: actions/setup-python@v1 22 | with: 23 | python-version: ${{ env.PYTHON_VERSION }} 24 | - name: Change Directory 25 | run: cd ${{ github.workspace }} 26 | - name: Setup Environment 27 | run: make environment 28 | - name: Lint 29 | run: make lint 30 | - name: Initialize CodeQL 31 | uses: github/codeql-action/init@v1 32 | with: 33 | languages: python 34 | - name: Autobuild 35 | uses: github/codeql-action/autobuild@v1 36 | - name: Perform CodeQL Analysis 37 | uses: github/codeql-action/analyze@v1 38 | 39 | linux_check: 40 | name: Linux Check 41 | runs-on: ubuntu-latest 42 | steps: 43 | - name: Checkout Code 44 | uses: actions/checkout@v2 45 | - name: Set up Python ${{ env.PYTHON_VERSION }} 46 | uses: actions/setup-python@v1 47 | with: 48 | python-version: ${{ env.PYTHON_VERSION }} 49 | - name: Change Directory 50 | run: cd ${{ github.workspace }} 51 | - name: Setup Environment 52 | run: make environment 53 | - name: Run Integration Tests 54 | run: make test-integration 55 | # - name: Run Postman Tests 56 | # run: make docker-postman-test 57 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Build 2 | 3 | on: 4 | milestone: 5 | types: [closed] 6 | repository_dispatch: 7 | types: [milestone_closed] 8 | 9 | env: 10 | PYTHON_VERSION: 3.9 11 | 12 | jobs: 13 | code_analysis: 14 | name: Code Analysis 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout Code 18 | uses: actions/checkout@v2 19 | - name: Set up Python ${{ env.PYTHON_VERSION }} 20 | uses: actions/setup-python@v1 21 | with: 22 | python-version: ${{ env.PYTHON_VERSION }} 23 | - name: Change Directory 24 | run: cd ${{ github.workspace }} 25 | - name: Setup Environment 26 | run: make environment 27 | - name: Lint 28 | run: make lint 29 | - name: Initialize CodeQL 30 | uses: github/codeql-action/init@v1 31 | with: 32 | languages: python 33 | - name: Autobuild 34 | uses: github/codeql-action/autobuild@v1 35 | - name: Perform CodeQL Analysis 36 | uses: github/codeql-action/analyze@v1 37 | 38 | linux_check: 39 | name: Linux Check 40 | runs-on: ubuntu-latest 41 | steps: 42 | - name: Checkout Code 43 | uses: actions/checkout@v2 44 | - name: Set up Python ${{ env.PYTHON_VERSION }} 45 | uses: actions/setup-python@v1 46 | with: 47 | python-version: ${{ env.PYTHON_VERSION }} 48 | - name: Change Directory 49 | run: cd ${{ github.workspace }} 50 | - name: Setup Environment 51 | run: make environment 52 | - name: Run Integration Tests 53 | run: make test-integration 54 | 55 | release: 56 | name: Release 57 | runs-on: ubuntu-latest 58 | needs: [code_analysis, linux_check] 59 | steps: 60 | - name: Checkout Code 61 | uses: actions/checkout@v2 62 | - name: Set up Python ${{ env.PYTHON_VERSION }} 63 | uses: actions/setup-python@v1 64 | with: 65 | python-version: ${{ env.PYTHON_VERSION }} 66 | - name: Change Directory 67 | run: cd ${{ github.workspace }} 68 | - name: Poetry Install 69 | run: pip install poetry 70 | - name: Get Version 71 | run: echo "name=PACKAGE_VERSION::$(echo $VERSION | poetry version | cut -c23-27)" >> $GITHUB_ENV 72 | - name: Create Release 73 | id: create_release 74 | uses: actions/create-release@v1.0.0 75 | env: 76 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 77 | with: 78 | tag_name: ${{ env.PACKAGE_VERSION }} 79 | release_name: Release ${{ env.PACKAGE_VERSION }} 80 | draft: false 81 | prerelease: false 82 | - name: Set up Docker Buildx 83 | uses: docker/setup-buildx-action@v1 84 | - name: Login to DockerHub 85 | uses: docker/login-action@v1 86 | with: 87 | username: ${{ secrets.DOCKERHUB_USER }} 88 | password: ${{ secrets.DOCKER_TOKEN }} 89 | - name: Build and Push to DockerHub 90 | id: docker_build 91 | uses: docker/build-push-action@v2 92 | with: 93 | push: true 94 | tags: | 95 | electionguard/electionguard-web-api:latest 96 | electionguard/electionguard-web-api:${{ env.PACKAGE_VERSION }} 97 | - name: Verify Docker Hub image 98 | run: echo ${{ steps.docker_build.outputs.digest }} 99 | - name: Push to GitHub Packages 100 | uses: docker/build-push-action@v1 101 | with: 102 | username: ${{ github.actor }} 103 | password: ${{ secrets.GITHUB_TOKEN }} 104 | registry: docker.pkg.github.com 105 | repository: microsoft/electionguard-web-api/electionguard-web-api 106 | tag_with_ref: true 107 | - name: Deploy Github Pages 108 | run: make docs-deploy-ci 109 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | # Cython debug symbols 138 | cython_debug/ 139 | 140 | # VS Code 141 | *.code-workspace 142 | 143 | # Mac 144 | .DS_Store 145 | 146 | # Project 147 | storage/ 148 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations. 3 | // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp 4 | 5 | // List of extensions which should be recommended for users of this workspace. 6 | "recommendations": [ 7 | "ms-python.python", 8 | "visualstudioexptteam.vscodeintellicode", 9 | "magicstack.magicpython", 10 | "hbenl.vscode-test-explorer", 11 | "littlefoxteam.vscode-python-test-adapter", 12 | "cschleiden.vscode-github-actions" 13 | ], 14 | // List of extensions recommended by VS Code that should not be recommended for users of this workspace. 15 | "unwantedRecommendations": [] 16 | } 17 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Guardian Web API", 6 | "type": "python", 7 | "request": "launch", 8 | "program": "${workspaceRoot}/app/main.py", 9 | "console": "integratedTerminal", 10 | "args": [ 11 | "--port", 12 | "8001" 13 | ], 14 | "env": { 15 | "PYTHONPATH": "${workspaceRoot}", 16 | "API_MODE": "guardian", 17 | "QUEUE_MODE": "remote", 18 | "STORAGE_MODE": "local_storage" 19 | } 20 | }, 21 | { 22 | "name": "Mediator Web API", 23 | "type": "python", 24 | "request": "launch", 25 | "program": "${workspaceRoot}/app/main.py", 26 | "console": "integratedTerminal", 27 | "args": [ 28 | "--port", 29 | "8000" 30 | ], 31 | "env": { 32 | "PYTHONPATH": "${workspaceRoot}", 33 | "API_MODE": "mediator", 34 | "QUEUE_MODE": "remote", 35 | "STORAGE_MODE": "local_storage" 36 | } 37 | } 38 | ] 39 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.formatting.provider": "black", 3 | "python.linting.mypyEnabled": true, 4 | "python.linting.pylintEnabled": true, 5 | "python.linting.pylintUseMinimalCheckers": false, 6 | "python.linting.pylintArgs": [ 7 | "--extension-pkg-whitelist=pydantic" 8 | ], 9 | "python.linting.enabled": true, 10 | "python.testing.unittestArgs": [ 11 | "-v", 12 | "-s", 13 | "./tests", 14 | "-p", 15 | "test_*.py" 16 | ], 17 | "python.testing.pytestEnabled": true, 18 | "python.testing.pytestArgs": [], 19 | "python.testing.nosetestsEnabled": false, 20 | "python.testing.unittestEnabled": false, 21 | "pythonTestExplorer.testFramework": "pytest", 22 | "files.exclude": { 23 | "**/__pycache__": true, 24 | "**/.hypothesis": true, 25 | "**/.mypy_cache": true, 26 | "**/.pytest_cache": true, 27 | "**/**.egg-info": true 28 | }, 29 | "editor.formatOnSave": true, 30 | "python.venvPath": ".venv", 31 | "python.pythonPath": ".venv/bin/python" 32 | } -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Microsoft Open Source Code of Conduct 2 | 3 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 4 | 5 | Resources: 6 | 7 | - [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/) 8 | - [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) 9 | - Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns 10 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | This project welcomes contributions and suggestions. Before you get started, you should read the [readme](README.md). 4 | 5 | - 🤔 **CONSIDER** adding a unit test if your PR resolves an issue. 6 | - ✅ **DO** check open PR's to avoid duplicates. 7 | - ✅ **DO** keep pull requests small so they can be easily reviewed. 8 | - ✅ **DO** build locally before pushing. 9 | - ✅ **DO** make sure tests pass. 10 | - ✅ **DO** make sure any new changes are documented. 11 | - ✅ **DO** make sure not to introduce any compiler warnings. 12 | - ❌**AVOID** breaking the continuous integration build. 13 | - ❌**AVOID** making significant changes to the overall architecture. 14 | 15 | ### Creating a Pull Request 16 | 17 | All pull requests should have an accompanying issue. Create one if there is not one matching your code. The code will be checked by continuous integration. Once this CI passes, the code will be reviewed, ideally approved, then merged. 18 | 19 | ### CLA 20 | 21 | Open source contributions require you to agree to a standard Microsoft Contributor License Agreement (CLA) declaring that you grant us the rights to use your contribution. For details, visit https://cla.opensource.microsoft.com. 22 | 23 | When you submit a pull request, a CLA bot will automatically determine whether you need to provide a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions provided by the bot. You will only need to do this once across all repos using our CLA. 24 | 25 | ### Code of Conduct 26 | 27 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM tiangolo/uvicorn-gunicorn-fastapi:python3.9 AS base 2 | ENV PORT 8000 3 | RUN apt update && apt-get install -y \ 4 | libgmp-dev \ 5 | libmpfr-dev \ 6 | libmpc-dev 7 | RUN pip install 'poetry==1.1.6' 8 | COPY ./pyproject.toml /tmp/ 9 | COPY ./poetry.lock /tmp/ 10 | RUN cd /tmp && poetry export -f requirements.txt > requirements.txt 11 | RUN pip install -r /tmp/requirements.txt 12 | EXPOSE $PORT 13 | 14 | FROM base AS dev 15 | VOLUME [ "/app/app" ] 16 | CMD /start-reload.sh 17 | 18 | FROM base AS prod 19 | COPY ./app /app/app 20 | # The base image will start gunicorn 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Microsoft Corporation. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all environment lint start 2 | 3 | OS ?= $(shell python -c 'import platform; print(platform.system())') 4 | IMAGE_NAME = electionguard_web_api 5 | AZURE_LOCATION = eastus 6 | RESOURCE_GROUP = EG-Deploy-Demo 7 | DEPLOY_REGISTRY = deploydemoregistry 8 | REGISTRY_SKU = Basic 9 | ACI_CONTEXT = egacicontext 10 | TENANT_ID = xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx 11 | GROUP_EXISTS ?= $(shell az group exists --name $(RESOURCE_GROUP)) 12 | 13 | # Supports either "guardian" or "mediator" modes 14 | API_MODE ?= mediator 15 | ifeq ($(API_MODE), mediator) 16 | PORT ?= 8000 17 | else 18 | PORT ?= 8001 19 | endif 20 | 21 | all: environment lint start 22 | 23 | environment: no-windows 24 | @echo 🔧 SETUP 25 | make install-gmp 26 | pip3 install 'poetry==1.1.6' 27 | poetry config virtualenvs.in-project true 28 | poetry install 29 | 30 | install: no-windows 31 | @echo 🔧 INSTALL 32 | poetry install 33 | 34 | install-gmp: no-windows 35 | @echo 📦 Install Module 36 | @echo Operating System identified as $(OS) 37 | ifeq ($(OS), Linux) 38 | make install-gmp-linux 39 | endif 40 | ifeq ($(OS), Darwin) 41 | make install-gmp-mac 42 | endif 43 | 44 | install-gmp-mac: no-windows 45 | @echo 🍎 MACOS INSTALL 46 | # gmpy2 requirements 47 | brew install gmp || true 48 | brew install mpfr || true 49 | brew install libmpc || true 50 | 51 | install-gmp-linux: no-windows 52 | @echo 🐧 LINUX INSTALL 53 | # gmpy2 requirements 54 | sudo apt-get install libgmp-dev 55 | sudo apt-get install libmpfr-dev 56 | sudo apt-get install libmpc-dev 57 | 58 | # install azure command line 59 | install-azure-cli: 60 | @echo Install Azure CLI 61 | ifeq ($(OS), Linux) 62 | curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash 63 | endif 64 | ifeq ($(OS), Darwin) 65 | brew install azure-cli 66 | az upgrade 67 | endif 68 | ifeq ($(OS), Windows) 69 | Invoke-WebRequest -Uri https://aka.ms/installazurecliwindows -OutFile .\AzureCLI.msi; Start-Process msiexec.exe -Wait -ArgumentList '/I AzureCLI.msi /quiet'; rm .\AzureCLI.msi 70 | endif 71 | 72 | # deploy to azure 73 | deploy-azure: 74 | @echo Deploy to Azure 75 | az login --tenant $(TENANT_ID) 76 | ifeq ($(GROUP_EXISTS), false) 77 | az group create -l $(AZURE_LOCATION) -n $(RESOURCE_GROUP) 78 | endif 79 | az acr create --resource-group $(RESOURCE_GROUP) --name $(DEPLOY_REGISTRY) --sku $(REGISTRY_SKU) 80 | az acr login --name $(DEPLOY_REGISTRY) 81 | docker context use default 82 | docker build . -t $(DEPLOY_REGISTRY).azurecr.io/electionguard-api-python:latest 83 | docker push $(DEPLOY_REGISTRY).azurecr.io/electionguard-api-python:latest 84 | docker login azure --tenant-id $(TENANT_ID) 85 | # docker context create aci $(ACI_CONTEXT) 86 | docker context use $(ACI_CONTEXT) 87 | docker compose -f docker-compose.azure.yml up 88 | docker logout 89 | docker context use default 90 | az logout 91 | 92 | # Dev Server 93 | start: no-windows 94 | poetry run uvicorn app.main:app --reload --port $(PORT) 95 | 96 | start-server: 97 | docker compose -f docker-compose.support.yml up -d 98 | QUEUE_MODE=remote 99 | STORAGE_MODE=mongo 100 | poetry run uvicorn app.main:app --reload --port $(PORT) 101 | 102 | stop: 103 | docker compose -f docker-compose.support.yml down 104 | 105 | # Docker 106 | docker-build: 107 | docker build -t $(IMAGE_NAME) . 108 | 109 | docker-run: 110 | docker-compose up --build 111 | 112 | docker-dev: 113 | docker-compose -f docker-compose.support.yml -f docker-compose.dev.yml up --build 114 | 115 | docker-postman-test: 116 | @echo 🧪 RUNNING POSTMAN TESTS IN DOCKER 117 | docker-compose \ 118 | -f tests/postman/docker-compose.yml up \ 119 | --build \ 120 | --abort-on-container-exit \ 121 | --exit-code-from test-runner 122 | 123 | # Linting 124 | lint: 125 | @echo 💚 LINT 126 | @echo 1.Pylint 127 | poetry run pylint --extension-pkg-whitelist=pydantic app tests 128 | @echo 2.Black Formatting 129 | poetry run black --diff --check app tests 130 | @echo 3.Mypy Static Typing 131 | poetry run mypy --config-file=pyproject.toml app tests 132 | @echo 4.Documentation 133 | poetry run mkdocs build --strict 134 | 135 | auto-lint: 136 | poetry run black app tests 137 | make lint 138 | 139 | test-integration: no-windows 140 | @echo ✅ INTEGRATION TESTS 141 | poetry run pytest -s . -x 142 | 143 | # Documentation 144 | docs-serve: 145 | poetry run mkdocs serve 146 | 147 | docs-build: 148 | poetry run mkdocs build 149 | 150 | docs-deploy: 151 | @echo 🚀 DEPLOY to Github Pages 152 | poetry run mkdocs gh-deploy --force 153 | 154 | docs-deploy-ci: 155 | @echo 🚀 DEPLOY to Github Pages 156 | pip install mkdocs 157 | mkdocs gh-deploy --force 158 | 159 | # PRIVATE RECIPIES 160 | 161 | no-windows: 162 | ifeq ($(OS), Windows_NT) 163 | @echo Windows is not supported. Instead run this command in WSL2. For more details see README.md. 164 | exit 1 165 | endif 166 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | <!-- BEGIN MICROSOFT SECURITY.MD V0.0.3 BLOCK --> 2 | 3 | ## Security 4 | 5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/Microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/). 6 | 7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets Microsoft's [Microsoft's definition of a security vulnerability](https://docs.microsoft.com/en-us/previous-versions/tn-archive/cc751383(v=technet.10)) of a security vulnerability, please report it to us as described below. 8 | 9 | ## Reporting Security Issues 10 | 11 | **Please do not report security vulnerabilities through public GitHub issues.** 12 | 13 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://msrc.microsoft.com/create-report). 14 | 15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the the [Microsoft Security Response Center PGP Key page](https://www.microsoft.com/en-us/msrc/pgp-key-msrc). 16 | 17 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://www.microsoft.com/msrc). 18 | 19 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 20 | 21 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 22 | * Full paths of source file(s) related to the manifestation of the issue 23 | * The location of the affected source code (tag/branch/commit or direct URL) 24 | * Any special configuration required to reproduce the issue 25 | * Step-by-step instructions to reproduce the issue 26 | * Proof-of-concept or exploit code (if possible) 27 | * Impact of the issue, including how an attacker might exploit the issue 28 | 29 | This information will help us triage your report more quickly. 30 | 31 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://microsoft.com/msrc/bounty) page for more details about our active programs. 32 | 33 | ## Preferred Languages 34 | 35 | We prefer all communications to be in English. 36 | 37 | ## Policy 38 | 39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://www.microsoft.com/en-us/msrc/cvd). 40 | 41 | <!-- END MICROSOFT SECURITY.MD BLOCK --> 42 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.0.5" 2 | -------------------------------------------------------------------------------- /app/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Election-Tech-Initiative/electionguard-api-python/eb26b45446a8f692709ba59d026832add8bddb43/app/api/__init__.py -------------------------------------------------------------------------------- /app/api/v1/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Election-Tech-Initiative/electionguard-api-python/eb26b45446a8f692709ba59d026832add8bddb43/app/api/v1/__init__.py -------------------------------------------------------------------------------- /app/api/v1/auth/__init__.py: -------------------------------------------------------------------------------- 1 | from .routes import router 2 | -------------------------------------------------------------------------------- /app/api/v1/auth/auth.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List, Optional 2 | 3 | from datetime import datetime, timedelta 4 | 5 | from fastapi import ( 6 | params, 7 | APIRouter, 8 | Depends, 9 | HTTPException, 10 | Request, 11 | Security, 12 | status, 13 | ) 14 | from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm 15 | from jose import JWTError, jwt 16 | from pydantic import ValidationError 17 | from app.api.v1.models.auth import ErrorMessage 18 | 19 | from app.api.v1.models.user import UserScope 20 | from app.core import Settings 21 | from app.core.user import get_user_info 22 | 23 | from ..models import Token, TokenData 24 | 25 | from ....core import AuthenticationContext 26 | 27 | from ..tags import AUTHORIZE 28 | 29 | router = APIRouter() 30 | 31 | oauth2_scheme = OAuth2PasswordBearer( 32 | tokenUrl="token", 33 | scopes={ 34 | UserScope.admin: "The admin role can execute administrative functions.", 35 | UserScope.auditor: "The auditor role is a readonly role that can observe the election", 36 | UserScope.guardian: "The guardian role can excute guardian functions.", 37 | UserScope.voter: "The voter role can execute voting functions such as encrypt ballot.", 38 | }, 39 | ) 40 | 41 | 42 | class ScopedTo(params.Depends): 43 | """Define a dependency on particular scope.""" 44 | 45 | username: str 46 | 47 | def __init__(self, scopes: List[UserScope]) -> None: 48 | super().__init__(self.__call__) 49 | self._scopes = scopes 50 | 51 | def __call__( 52 | self, 53 | token: str = Security(oauth2_scheme), 54 | ) -> TokenData: 55 | """Check scopes and return the current user.""" 56 | data = validate_access_token(Settings(), token) 57 | validate_access_token_authorization(data, self._scopes) 58 | return data 59 | 60 | 61 | def validate_access_token_authorization( 62 | token_data: TokenData, scopes: List[UserScope] 63 | ) -> None: 64 | """Validate that the access token is authorized to the requested resource.""" 65 | if any(scopes): 66 | scope_str = ",".join(scopes) 67 | authenticate_value = f'Bearer scope="{scope_str}"' 68 | else: 69 | authenticate_value = "Bearer" 70 | for scope in scopes: 71 | if scope in token_data.scopes: 72 | return 73 | raise HTTPException( 74 | status_code=status.HTTP_403_FORBIDDEN, 75 | detail="Not enough permissions", 76 | headers={"WWW-Authenticate": authenticate_value}, 77 | ) 78 | 79 | 80 | def create_access_token( 81 | data: dict, 82 | expires_delta: Optional[timedelta] = None, 83 | settings: Settings = Settings(), 84 | ) -> Any: 85 | """Create an access token.""" 86 | to_encode = data.copy() 87 | if expires_delta: 88 | expire = datetime.utcnow() + expires_delta 89 | else: 90 | expire = datetime.utcnow() + timedelta( 91 | minutes=settings.AUTH_ACCESS_TOKEN_EXPIRE_MINUTES 92 | ) 93 | to_encode.update({"exp": expire}) 94 | encoded_jwt = jwt.encode( 95 | to_encode, settings.AUTH_SECRET_KEY, algorithm=settings.AUTH_ALGORITHM 96 | ) 97 | return encoded_jwt 98 | 99 | 100 | def validate_access_token( 101 | settings: Settings = Settings(), token: str = Depends(oauth2_scheme) 102 | ) -> TokenData: 103 | """validate the token contains a username and scopes""" 104 | try: 105 | payload = jwt.decode( 106 | token, 107 | settings.AUTH_SECRET_KEY, 108 | algorithms=[settings.AUTH_ALGORITHM], 109 | ) 110 | username: str = payload.get("sub") 111 | if username is None: 112 | raise HTTPException( 113 | status_code=status.HTTP_401_UNAUTHORIZED, 114 | detail="Could not validate credentials", 115 | headers={"WWW-Authenticate": "Bearer"}, 116 | ) 117 | token_scopes = payload.get("scopes") 118 | token_data = TokenData(username=username, scopes=token_scopes) 119 | except (JWTError, ValidationError) as internal_error: 120 | raise HTTPException( 121 | status_code=status.HTTP_401_UNAUTHORIZED, 122 | detail="Could not validate credential scopes", 123 | headers={"WWW-Authenticate": "Bearer"}, 124 | ) from internal_error 125 | return token_data 126 | 127 | 128 | @router.post( 129 | "/login", 130 | response_model=Token, 131 | tags=[AUTHORIZE], 132 | responses={401: {"model": ErrorMessage}, 404: {"model": ErrorMessage}}, 133 | ) 134 | async def login_for_access_token( 135 | request: Request, form_data: OAuth2PasswordRequestForm = Depends() 136 | ) -> Token: 137 | """Log in using the provided username and password.""" 138 | authenticated = AuthenticationContext( 139 | request.app.state.settings 140 | ).authenticate_credential(form_data.username, form_data.password) 141 | if not authenticated: 142 | raise HTTPException( 143 | status_code=status.HTTP_401_UNAUTHORIZED, 144 | detail="Incorrect username or password", 145 | headers={"WWW-Authenticate": "Bearer"}, 146 | ) 147 | # get the database cached user info 148 | user_info = get_user_info(form_data.username, request.app.state.settings) 149 | access_token_expires = timedelta( 150 | minutes=request.app.state.settings.AUTH_ACCESS_TOKEN_EXPIRE_MINUTES 151 | ) 152 | access_token = create_access_token( 153 | data={"sub": form_data.username, "scopes": user_info.scopes}, 154 | expires_delta=access_token_expires, 155 | ) 156 | return Token(access_token=access_token, token_type="bearer") 157 | 158 | 159 | # TODO: add refresh support 160 | -------------------------------------------------------------------------------- /app/api/v1/auth/routes.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | from . import auth 3 | from . import user 4 | 5 | router = APIRouter() 6 | 7 | router.include_router(auth.router, prefix="/auth") 8 | router.include_router(user.router, prefix="/user") 9 | -------------------------------------------------------------------------------- /app/api/v1/auth/user.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from base64 import b64encode, b16decode 3 | from fastapi import APIRouter, Body, Depends, HTTPException, Request, status 4 | from electionguard.serializable import write_json_object 5 | 6 | from electionguard.group import rand_q 7 | 8 | from app.api.v1.models.user import ( 9 | CreateUserResponse, 10 | UserQueryRequest, 11 | UserQueryResponse, 12 | ) 13 | 14 | from .auth import ScopedTo 15 | 16 | from ..models import ( 17 | AuthenticationCredential, 18 | UserInfo, 19 | UserScope, 20 | ) 21 | 22 | from ....core import ( 23 | AuthenticationContext, 24 | get_user_info, 25 | set_user_info, 26 | filter_user_info, 27 | get_auth_credential, 28 | set_auth_credential, 29 | update_auth_credential, 30 | ) 31 | 32 | from ..tags import USER 33 | 34 | router = APIRouter() 35 | 36 | 37 | @router.post( 38 | "/find", 39 | response_model=UserQueryResponse, 40 | dependencies=[ScopedTo([UserScope.admin])], 41 | tags=[USER], 42 | ) 43 | async def find_users( 44 | request: Request, 45 | skip: int = 0, 46 | limit: int = 100, 47 | data: UserQueryRequest = Body(...), 48 | ) -> UserQueryResponse: 49 | """ 50 | Find users. 51 | 52 | Search the repository for users that match the filter criteria specified in the request body. 53 | If no filter criteria is specified the API will return all users. 54 | """ 55 | filter = write_json_object(data.filter) if data.filter else {} 56 | users = filter_user_info(filter, skip, limit, request.app.state.settings) 57 | return UserQueryResponse(users=users) 58 | 59 | 60 | scoped_to_any = ScopedTo( 61 | [UserScope.admin, UserScope.auditor, UserScope.guardian, UserScope.voter] 62 | ) 63 | 64 | 65 | @router.get( 66 | "/me", 67 | response_model=UserInfo, 68 | dependencies=[ 69 | ScopedTo( 70 | [UserScope.admin, UserScope.auditor, UserScope.guardian, UserScope.voter] 71 | ) 72 | ], 73 | tags=[USER], 74 | ) 75 | async def me( 76 | request: Request, token_data: ScopedTo = Depends(scoped_to_any) 77 | ) -> UserInfo: 78 | """ 79 | Get user info for the current logged in user. 80 | """ 81 | 82 | if token_data.username is None: 83 | raise HTTPException( 84 | status_code=status.HTTP_400_BAD_REQUEST, detail="User not specified" 85 | ) 86 | 87 | current_user = get_user_info(token_data.username, request.app.state.settings) 88 | 89 | if current_user.disabled: 90 | raise HTTPException( 91 | status_code=status.HTTP_400_BAD_REQUEST, detail="Inactive user" 92 | ) 93 | return current_user 94 | 95 | 96 | @router.put( 97 | "", 98 | response_model=CreateUserResponse, 99 | dependencies=[ScopedTo([UserScope.admin])], 100 | tags=[USER], 101 | ) 102 | def create_user( 103 | request: Request, 104 | user_info: UserInfo = Body(...), 105 | ) -> CreateUserResponse: 106 | """Create a new user.""" 107 | 108 | if any( 109 | filter_user_info( 110 | filter={"username": user_info.username}, 111 | skip=0, 112 | limit=1, 113 | settings=request.app.state.settings, 114 | ) 115 | ): 116 | raise HTTPException( 117 | status_code=status.HTTP_409_CONFLICT, detail="User already exists" 118 | ) 119 | 120 | # TODO: generate passwords differently 121 | new_password = b64encode(b16decode(rand_q().to_hex()[0:16])) 122 | hashed_password = AuthenticationContext( 123 | request.app.state.settings 124 | ).get_password_hash(new_password) 125 | credential = AuthenticationCredential( 126 | username=user_info.username, hashed_password=hashed_password 127 | ) 128 | 129 | set_auth_credential(credential, request.app.state.settings) 130 | set_user_info(user_info, request.app.state.settings) 131 | 132 | return CreateUserResponse(user_info=user_info, password=new_password) 133 | 134 | 135 | @router.post( 136 | "/reset_password", 137 | dependencies=[ScopedTo([UserScope.admin])], 138 | tags=[USER], 139 | ) 140 | async def reset_password(request: Request, username: str) -> Any: 141 | """Reset a user's password.""" 142 | 143 | credential = get_auth_credential( 144 | username, 145 | settings=request.app.state.settings, 146 | ) 147 | 148 | # TODO: generate passwords differently 149 | new_password = b64encode(b16decode(rand_q().to_hex()[0:16])) 150 | credential.hashed_password = AuthenticationContext( 151 | request.app.state.settings 152 | ).get_password_hash(new_password) 153 | 154 | update_auth_credential(credential, request.app.state.settings) 155 | 156 | return {"username": username, "password": new_password} 157 | -------------------------------------------------------------------------------- /app/api/v1/common/__init__.py: -------------------------------------------------------------------------------- 1 | from .routes import router 2 | -------------------------------------------------------------------------------- /app/api/v1/common/ping.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from fastapi import APIRouter 4 | 5 | from ..tags import UTILITY 6 | 7 | router = APIRouter() 8 | 9 | 10 | @router.get("", response_model=str, tags=[UTILITY]) 11 | def ping() -> Any: 12 | """ 13 | Ensure API can be pinged 14 | """ 15 | return "pong" 16 | -------------------------------------------------------------------------------- /app/api/v1/common/routes.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | from . import ping 3 | 4 | router = APIRouter() 5 | 6 | router.include_router(ping.router, prefix="/ping") 7 | -------------------------------------------------------------------------------- /app/api/v1/common/type_mapper.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | from electionguard.group import ( 3 | ElementModP, 4 | ElementModQ, 5 | hex_to_p, 6 | hex_to_q, 7 | int_to_p, 8 | int_to_q, 9 | ) 10 | 11 | 12 | def string_to_element_mod_p(value: Union[int, str]) -> ElementModP: 13 | element = int_to_p(value) if isinstance(value, int) else hex_to_p(value) 14 | if element is None: 15 | raise ValueError(type_error_message(str(value), "element_mod_p")) 16 | return element 17 | 18 | 19 | def string_to_element_mod_q(value: Union[int, str]) -> ElementModQ: 20 | element = int_to_q(value) if isinstance(value, int) else hex_to_q(value) 21 | if element is None: 22 | raise ValueError(type_error_message(str(value), "element_mod_q")) 23 | return element 24 | 25 | 26 | def type_error_message(value: str, type: str) -> str: 27 | return f"{value} cannot be converted to {type}." 28 | -------------------------------------------------------------------------------- /app/api/v1/guardian/__init__.py: -------------------------------------------------------------------------------- 1 | from .routes import router 2 | -------------------------------------------------------------------------------- /app/api/v1/guardian/ballot.py: -------------------------------------------------------------------------------- 1 | from electionguard.ballot import SubmittedBallot 2 | from electionguard.decryption import compute_decryption_share_for_ballot 3 | from electionguard.election import CiphertextElectionContext 4 | from electionguard.key_ceremony import ElectionKeyPair 5 | from electionguard.scheduler import Scheduler 6 | from electionguard.serializable import read_json_object, write_json_object 7 | from fastapi import APIRouter, Body, Depends 8 | 9 | from app.core.scheduler import get_scheduler 10 | from ..models import ( 11 | DecryptBallotSharesRequest, 12 | DecryptBallotSharesResponse, 13 | ) 14 | from ..tags import TALLY 15 | 16 | router = APIRouter() 17 | 18 | 19 | @router.post( 20 | "/decrypt-shares", response_model=DecryptBallotSharesResponse, tags=[TALLY] 21 | ) 22 | def decrypt_ballot_shares( 23 | request: DecryptBallotSharesRequest = Body(...), 24 | scheduler: Scheduler = Depends(get_scheduler), 25 | ) -> DecryptBallotSharesResponse: 26 | """ 27 | Decrypt this guardian's share of one or more ballots 28 | """ 29 | ballots = [ 30 | SubmittedBallot.from_json_object(ballot) for ballot in request.encrypted_ballots 31 | ] 32 | context = CiphertextElectionContext.from_json_object(request.context) 33 | election_key_pair = read_json_object( 34 | request.guardian.election_keys, ElectionKeyPair 35 | ) 36 | 37 | shares = [ 38 | compute_decryption_share_for_ballot( 39 | election_key_pair, ballot, context, scheduler 40 | ) 41 | for ballot in ballots 42 | ] 43 | 44 | response = DecryptBallotSharesResponse( 45 | shares=[write_json_object(share) for share in shares] 46 | ) 47 | 48 | return response 49 | -------------------------------------------------------------------------------- /app/api/v1/guardian/routes.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | from . import ballot 3 | from . import guardian 4 | from . import tally_decrypt 5 | 6 | router = APIRouter() 7 | 8 | router.include_router(guardian.router, prefix="/guardian") 9 | router.include_router(ballot.router, prefix="/ballot") 10 | router.include_router(tally_decrypt.router, prefix="/tally") 11 | -------------------------------------------------------------------------------- /app/api/v1/guardian/tally_decrypt.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=unused-argument 2 | from datetime import datetime 3 | from typing import Dict 4 | from electionguard.scheduler import Scheduler 5 | from electionguard.manifest import ElectionType, Manifest, InternalManifest 6 | from electionguard.tally import CiphertextTally, CiphertextTallyContest 7 | from electionguard.serializable import read_json_object, write_json_object 8 | from electionguard.type import CONTEST_ID 9 | from fastapi import APIRouter, Body, Depends, HTTPException, Request, status 10 | 11 | from app.core.scheduler import get_scheduler 12 | from app.core.guardian import get_guardian 13 | from app.core.tally_decrypt import get_decryption_share, set_decryption_share 14 | from ..models import ( 15 | to_sdk_guardian, 16 | DecryptTallyShareRequest, 17 | CiphertextTallyDecryptionShare, 18 | DecryptionShareResponse, 19 | ) 20 | from ..tags import TALLY_DECRYPT 21 | 22 | router = APIRouter() 23 | 24 | 25 | @router.get( 26 | "/decrypt-share", response_model=DecryptionShareResponse, tags=[TALLY_DECRYPT] 27 | ) 28 | def fetch_decrypt_share( 29 | request: Request, 30 | election_id: str, 31 | tally_name: str, 32 | ) -> DecryptionShareResponse: 33 | """ 34 | Fetch A decryption share for a given tally 35 | """ 36 | share = get_decryption_share(election_id, tally_name, request.app.state.settings) 37 | return DecryptionShareResponse(shares=[share]) 38 | 39 | 40 | @router.post( 41 | "/decrypt-share", response_model=DecryptionShareResponse, tags=[TALLY_DECRYPT] 42 | ) 43 | def decrypt_share( 44 | request: Request, 45 | data: DecryptTallyShareRequest = Body(...), 46 | scheduler: Scheduler = Depends(get_scheduler), 47 | ) -> DecryptionShareResponse: 48 | """ 49 | Decrypt a single guardian's share of a tally 50 | """ 51 | guardian = get_guardian(data.guardian_id, request.app.state.settings) 52 | context = data.context.to_sdk_format() 53 | 54 | # TODO: HACK: Remove The Empty Manifest 55 | # Note: The CiphertextTally requires an internal manifest passed into its constructor 56 | # but it is not actually used when executing `compute_decryption_share` so we create a fake. 57 | # see: https://github.com/microsoft/electionguard-python/issues/391 58 | internal_manifest = InternalManifest( 59 | Manifest( 60 | "", 61 | "", 62 | ElectionType.other, 63 | datetime.now(), 64 | datetime.now(), 65 | [], 66 | [], 67 | [], 68 | [], 69 | [], 70 | ) 71 | ) 72 | tally = CiphertextTally(data.encrypted_tally.tally_name, internal_manifest, context) 73 | contests: Dict[CONTEST_ID, CiphertextTallyContest] = { 74 | contest_id: read_json_object(contest, CiphertextTallyContest) 75 | for contest_id, contest in data.encrypted_tally.tally["contests"].items() 76 | } 77 | tally.contests = contests 78 | 79 | # TODO: modify compute_tally_share to include an optional scheduler param 80 | sdk_guardian = to_sdk_guardian(guardian) 81 | sdk_tally_share = sdk_guardian.compute_tally_share(tally, context) 82 | 83 | if not sdk_tally_share: 84 | raise HTTPException( 85 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 86 | detail="Could not compute tally share", 87 | ) 88 | 89 | share = CiphertextTallyDecryptionShare( 90 | election_id=data.encrypted_tally.election_id, 91 | tally_name=data.encrypted_tally.tally_name, 92 | guardian_id=guardian.guardian_id, 93 | tally_share=write_json_object(sdk_tally_share), 94 | # TODO: include spoiled ballots 95 | ) 96 | 97 | set_decryption_share(share, request.app.state.settings) 98 | 99 | return DecryptionShareResponse(shares=[share]) 100 | -------------------------------------------------------------------------------- /app/api/v1/mediator/__init__.py: -------------------------------------------------------------------------------- 1 | from .routes import router 2 | -------------------------------------------------------------------------------- /app/api/v1/mediator/decrypt.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List 2 | 3 | from electionguard.ballot import SubmittedBallot 4 | from electionguard.ballot_box import BallotBoxState 5 | from electionguard.decrypt_with_shares import decrypt_ballot 6 | from electionguard.decryption_share import DecryptionShare 7 | from electionguard.election import CiphertextElectionContext 8 | from electionguard.serializable import read_json_object, write_json_object 9 | from electionguard.type import BALLOT_ID, GUARDIAN_ID 10 | from fastapi import APIRouter, Body, HTTPException 11 | 12 | from ..models import DecryptBallotsWithSharesRequest 13 | from ..tags import ENCRYPT 14 | 15 | router = APIRouter() 16 | 17 | 18 | @router.post("/decrypt", tags=[ENCRYPT]) 19 | def decrypt_ballots(request: DecryptBallotsWithSharesRequest = Body(...)) -> Any: 20 | """Decrypt ballots with the provided shares""" 21 | ballots = [ 22 | SubmittedBallot.from_json_object(ballot) for ballot in request.encrypted_ballots 23 | ] 24 | context: CiphertextElectionContext = CiphertextElectionContext.from_json_object( 25 | request.context 26 | ) 27 | 28 | # only decrypt spoiled ballots using this API 29 | for ballot in ballots: 30 | if ballot.state != BallotBoxState.SPOILED: 31 | raise HTTPException( 32 | status_code=400, 33 | detail=f"Ballot {ballot.object_id} must be spoiled", 34 | ) 35 | 36 | all_shares: List[DecryptionShare] = [ 37 | read_json_object(share, DecryptionShare) 38 | for shares in request.shares.values() 39 | for share in shares 40 | ] 41 | shares_by_ballot = index_shares_by_ballot(all_shares) 42 | 43 | extended_base_hash = context.crypto_extended_base_hash 44 | decrypted_ballots = { 45 | ballot.object_id: decrypt_ballot( 46 | ballot, shares_by_ballot[ballot.object_id], extended_base_hash 47 | ) 48 | for ballot in ballots 49 | } 50 | 51 | return write_json_object(decrypted_ballots) 52 | 53 | 54 | def index_shares_by_ballot( 55 | shares: List[DecryptionShare], 56 | ) -> Dict[BALLOT_ID, Dict[GUARDIAN_ID, DecryptionShare]]: 57 | """ 58 | Construct a lookup by ballot ID containing the dictionary of shares needed 59 | to decrypt that ballot. 60 | """ 61 | shares_by_ballot: Dict[str, Dict[str, DecryptionShare]] = {} 62 | for share in shares: 63 | ballot_shares = shares_by_ballot.setdefault(share.object_id, {}) 64 | ballot_shares[share.guardian_id] = share 65 | 66 | return shares_by_ballot 67 | -------------------------------------------------------------------------------- /app/api/v1/mediator/encrypt.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Body, HTTPException, Request, status 2 | 3 | from electionguard.ballot import PlaintextBallot 4 | from electionguard.manifest import InternalManifest, Manifest 5 | from electionguard.encrypt import encrypt_ballot 6 | from electionguard.group import ElementModQ 7 | from electionguard.serializable import read_json_object, write_json_object 8 | from electionguard.utils import get_optional 9 | 10 | from app.core.election import get_election 11 | from ..models import ( 12 | EncryptBallotsRequest, 13 | EncryptBallotsResponse, 14 | ) 15 | from ..tags import ENCRYPT 16 | 17 | router = APIRouter() 18 | 19 | 20 | @router.post("/encrypt", tags=[ENCRYPT]) 21 | def encrypt_ballots( 22 | request: Request, 23 | data: EncryptBallotsRequest = Body(...), 24 | ) -> EncryptBallotsResponse: 25 | """ 26 | Encrypt one or more ballots. 27 | 28 | This function is primarily used for testing and does not modify internal state. 29 | """ 30 | election = get_election(data.election_id, request.app.state.settings) 31 | manifest = InternalManifest(Manifest.from_json_object(election.manifest)) 32 | context = election.context.to_sdk_format() 33 | seed_hash = read_json_object(data.seed_hash, ElementModQ) 34 | 35 | ballots = [PlaintextBallot.from_json_object(ballot) for ballot in data.ballots] 36 | 37 | encrypted_ballots = [] 38 | current_hash = seed_hash 39 | 40 | for ballot in ballots: 41 | encrypted_ballot = encrypt_ballot(ballot, manifest, context, current_hash) 42 | if not encrypted_ballot: 43 | raise HTTPException( 44 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 45 | detail="Ballot failed to encrypt", 46 | ) 47 | encrypted_ballots.append(encrypted_ballot) 48 | current_hash = get_optional(encrypted_ballot.crypto_hash) 49 | 50 | response = EncryptBallotsResponse( 51 | message="Successfully encrypted ballots", 52 | encrypted_ballots=[ballot.to_json_object() for ballot in encrypted_ballots], 53 | next_seed_hash=write_json_object(current_hash), 54 | ) 55 | return response 56 | -------------------------------------------------------------------------------- /app/api/v1/mediator/key_ceremony.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Body, HTTPException, Request, status 2 | 3 | from electionguard.key_ceremony import ( 4 | PublicKeySet, 5 | ElectionPartialKeyBackup, 6 | ElectionPartialKeyVerification, 7 | ElectionPartialKeyChallenge, 8 | ) 9 | from electionguard.serializable import write_json_object, read_json_object 10 | 11 | from ....core.key_guardian import get_key_guardian, update_key_guardian 12 | from ....core.key_ceremony import get_key_ceremony, update_key_ceremony 13 | from ..models import ( 14 | BaseResponse, 15 | GuardianAnnounceRequest, 16 | GuardianSubmitBackupRequest, 17 | GuardianSubmitVerificationRequest, 18 | GuardianSubmitChallengeRequest, 19 | KeyCeremony, 20 | KeyCeremonyState, 21 | KeyCeremonyGuardian, 22 | KeyCeremonyGuardianStatus, 23 | ) 24 | from ..tags import KEY_CEREMONY 25 | 26 | router = APIRouter() 27 | 28 | 29 | # ROUND 1: Announce guardians with public keys 30 | @router.post("/guardian/announce", response_model=BaseResponse, tags=[KEY_CEREMONY]) 31 | def announce_guardian( 32 | request: Request, 33 | data: GuardianAnnounceRequest = Body(...), 34 | ) -> BaseResponse: 35 | """ 36 | Announce the guardian as present and participating in the Key Ceremony. 37 | """ 38 | keyset = read_json_object(data.public_keys, PublicKeySet) 39 | guardian_id = keyset.election.owner_id 40 | 41 | ceremony = get_key_ceremony(data.key_name, request.app.state.settings) 42 | guardian = get_key_guardian(data.key_name, guardian_id, request.app.state.settings) 43 | 44 | _validate_can_participate(ceremony, guardian) 45 | 46 | guardian.public_keys = write_json_object(keyset) 47 | ceremony.guardian_status[ 48 | guardian_id 49 | ].public_key_shared = KeyCeremonyGuardianStatus.COMPLETE 50 | 51 | update_key_guardian( 52 | data.key_name, guardian_id, guardian, request.app.state.settings 53 | ) 54 | return update_key_ceremony(data.key_name, ceremony, request.app.state.settings) 55 | 56 | 57 | # ROUND 2: Share Election Partial Key Backups for compensating 58 | @router.post("/guardian/backup", response_model=BaseResponse, tags=[KEY_CEREMONY]) 59 | def share_backups( 60 | request: Request, 61 | data: GuardianSubmitBackupRequest = Body(...), 62 | ) -> BaseResponse: 63 | """ 64 | Share Election Partial Key Backups to be distributed to the other guardians. 65 | """ 66 | ceremony = get_key_ceremony(data.key_name, request.app.state.settings) 67 | guardian = get_key_guardian( 68 | data.key_name, data.guardian_id, request.app.state.settings 69 | ) 70 | 71 | _validate_can_participate(ceremony, guardian) 72 | 73 | backups = [ 74 | read_json_object(backup, ElectionPartialKeyBackup) for backup in data.backups 75 | ] 76 | 77 | guardian.backups = [write_json_object(backup) for backup in backups] 78 | ceremony.guardian_status[ 79 | data.guardian_id 80 | ].backups_shared = KeyCeremonyGuardianStatus.COMPLETE 81 | 82 | update_key_guardian( 83 | data.key_name, data.guardian_id, guardian, request.app.state.settings 84 | ) 85 | return update_key_ceremony(data.key_name, ceremony, request.app.state.settings) 86 | 87 | 88 | # ROUND 3: Share verifications of backups 89 | @router.post("/guardian/verify", response_model=BaseResponse, tags=[KEY_CEREMONY]) 90 | def verify_backups( 91 | request: Request, 92 | data: GuardianSubmitVerificationRequest = Body(...), 93 | ) -> BaseResponse: 94 | """ 95 | Share the results of verifying the other guardians' backups. 96 | """ 97 | ceremony = get_key_ceremony(data.key_name, request.app.state.settings) 98 | guardian = get_key_guardian( 99 | data.key_name, data.guardian_id, request.app.state.settings 100 | ) 101 | 102 | _validate_can_participate(ceremony, guardian) 103 | 104 | verifications = [ 105 | read_json_object(verification, ElectionPartialKeyVerification) 106 | for verification in data.verifications 107 | ] 108 | 109 | guardian.verifications = [ 110 | write_json_object(verification) for verification in verifications 111 | ] 112 | # pylint: disable=use-a-generator 113 | ceremony.guardian_status[data.guardian_id].backups_verified = ( 114 | KeyCeremonyGuardianStatus.COMPLETE 115 | if all([verification.verified for verification in verifications]) 116 | else KeyCeremonyGuardianStatus.ERROR 117 | ) 118 | 119 | update_key_guardian( 120 | data.key_name, data.guardian_id, guardian, request.app.state.settings 121 | ) 122 | return update_key_ceremony(data.key_name, ceremony, request.app.state.settings) 123 | 124 | 125 | # ROUND 4 (Optional): If a verification fails, guardian must issue challenge 126 | @router.post("/guardian/challenge", response_model=BaseResponse, tags=[KEY_CEREMONY]) 127 | def challenge_backups( 128 | request: Request, 129 | data: GuardianSubmitChallengeRequest = Body(...), 130 | ) -> BaseResponse: 131 | """ 132 | Submit challenges to the other guardians' backups. 133 | """ 134 | ceremony = get_key_ceremony(data.key_name, request.app.state.settings) 135 | guardian = get_key_guardian( 136 | data.key_name, data.guardian_id, request.app.state.settings 137 | ) 138 | 139 | _validate_can_participate(ceremony, guardian) 140 | 141 | challenges = [ 142 | read_json_object(challenge, ElectionPartialKeyChallenge) 143 | for challenge in data.challenges 144 | ] 145 | 146 | guardian.challenges = [write_json_object(challenge) for challenge in challenges] 147 | ceremony.guardian_status[ 148 | data.guardian_id 149 | ].backups_verified = KeyCeremonyGuardianStatus.ERROR 150 | 151 | update_key_guardian( 152 | data.key_name, data.guardian_id, guardian, request.app.state.settings 153 | ) 154 | return update_key_ceremony(data.key_name, ceremony, request.app.state.settings) 155 | 156 | 157 | def _validate_can_participate( 158 | ceremony: KeyCeremony, guardian: KeyCeremonyGuardian 159 | ) -> None: 160 | # TODO: better validation 161 | if ceremony.state != KeyCeremonyState.OPEN: 162 | raise HTTPException( 163 | status_code=status.HTTP_403_FORBIDDEN, 164 | detail=f"Cannot announce for key ceremony state {ceremony.state}", 165 | ) 166 | 167 | if guardian.guardian_id not in ceremony.guardian_ids: 168 | raise HTTPException( 169 | status_code=status.HTTP_403_FORBIDDEN, 170 | detail=f"Guardian {guardian.guardian_id} not in ceremony", 171 | ) 172 | -------------------------------------------------------------------------------- /app/api/v1/mediator/key_guardian.py: -------------------------------------------------------------------------------- 1 | import traceback 2 | from typing import List 3 | import sys 4 | from fastapi import APIRouter, Body, HTTPException, Request, status 5 | 6 | from electionguard.serializable import write_json_object, read_json_object 7 | 8 | from ....core.client import get_client_id 9 | from ....core.key_guardian import get_key_guardian, update_key_guardian 10 | from ....core.repository import get_repository, DataCollection 11 | from ..models import ( 12 | BaseQueryRequest, 13 | BaseResponse, 14 | GuardianQueryResponse, 15 | KeyCeremonyGuardian, 16 | ) 17 | from ..tags import KEY_GUARDIAN 18 | 19 | router = APIRouter() 20 | 21 | 22 | @router.get("", response_model=GuardianQueryResponse, tags=[KEY_GUARDIAN]) 23 | def fetch_key_ceremony_guardian( 24 | request: Request, key_name: str, guardian_id: str 25 | ) -> GuardianQueryResponse: 26 | """ 27 | Get a key ceremony guardian. 28 | """ 29 | guardian = get_key_guardian(key_name, guardian_id, request.app.state.settings) 30 | return GuardianQueryResponse(guardians=[guardian]) 31 | 32 | 33 | @router.put("", response_model=BaseResponse, tags=[KEY_GUARDIAN]) 34 | def create_key_ceremony_guardian( 35 | request: Request, 36 | data: KeyCeremonyGuardian = Body(...), 37 | ) -> BaseResponse: 38 | """ 39 | Create a Key Ceremony Guardian. 40 | 41 | In order for a guardian to participate they must be associated with the key ceremony first. 42 | """ 43 | try: 44 | with get_repository( 45 | get_client_id(), DataCollection.KEY_GUARDIAN, request.app.state.settings 46 | ) as repository: 47 | query_result = repository.get( 48 | {"key_name": data.key_name, "guardian_id": data.guardian_id} 49 | ) 50 | if not query_result: 51 | repository.set(data.dict()) 52 | return BaseResponse() 53 | raise HTTPException( 54 | status_code=status.HTTP_409_CONFLICT, 55 | detail=f"Already exists {data.guardian_id}", 56 | ) 57 | except Exception as error: 58 | traceback.print_exc() 59 | print(sys.exc_info()) 60 | raise HTTPException( 61 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 62 | detail="Submit ballots failed", 63 | ) from error 64 | 65 | 66 | @router.post("", response_model=BaseResponse, tags=[KEY_GUARDIAN]) 67 | def update_key_ceremony_guardian( 68 | request: Request, 69 | data: KeyCeremonyGuardian = Body(...), 70 | ) -> BaseResponse: 71 | """ 72 | Update a Key Ceremony Guardian. 73 | 74 | This API is primarily for administrative purposes. 75 | """ 76 | return update_key_guardian( 77 | data.key_name, data.guardian_id, data, request.app.state.settings 78 | ) 79 | 80 | 81 | @router.post("/find", response_model=GuardianQueryResponse, tags=[KEY_GUARDIAN]) 82 | def find_key_ceremony_guardians( 83 | request: Request, 84 | skip: int = 0, 85 | limit: int = 100, 86 | data: BaseQueryRequest = Body(...), 87 | ) -> GuardianQueryResponse: 88 | """ 89 | Find Guardians. 90 | 91 | Search the repository for guardians that match the filter criteria specified in the request body. 92 | If no filter criteria is specified the API will iterate all available data. 93 | """ 94 | try: 95 | filter = write_json_object(data.filter) if data.filter else {} 96 | with get_repository( 97 | get_client_id(), DataCollection.KEY_GUARDIAN, request.app.state.settings 98 | ) as repository: 99 | cursor = repository.find(filter, skip, limit) 100 | guardians: List[KeyCeremonyGuardian] = [] 101 | for item in cursor: 102 | guardians.append(read_json_object(item, KeyCeremonyGuardian)) 103 | return GuardianQueryResponse(guardians=guardians) 104 | except Exception as error: 105 | print(sys.exc_info()) 106 | raise HTTPException( 107 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 108 | detail="find guardians failed", 109 | ) from error 110 | -------------------------------------------------------------------------------- /app/api/v1/mediator/manifest.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional, Tuple 2 | 3 | from fastapi import APIRouter, Body, Depends, HTTPException, Request, status 4 | 5 | from electionguard.group import hex_to_q 6 | from electionguard.manifest import Manifest as sdk_manifest 7 | from electionguard.schema import validate_json_schema 8 | from electionguard.serializable import write_json_object 9 | from electionguard.utils import get_optional 10 | 11 | from app.core.schema import get_description_schema 12 | 13 | from ....core.manifest import get_manifest, set_manifest, filter_manifests 14 | from ..models import ( 15 | Manifest, 16 | BaseQueryRequest, 17 | ManifestQueryResponse, 18 | ManifestSubmitResponse, 19 | ValidateManifestRequest, 20 | ValidateManifestResponse, 21 | ResponseStatus, 22 | ) 23 | from ..tags import MANIFEST 24 | 25 | router = APIRouter() 26 | 27 | 28 | @router.get("", response_model=ManifestQueryResponse, tags=[MANIFEST]) 29 | def fetch_manifest(request: Request, manifest_hash: str) -> ManifestQueryResponse: 30 | """Get an election manifest by hash.""" 31 | crypto_hash = hex_to_q(manifest_hash) 32 | if not crypto_hash: 33 | raise HTTPException( 34 | status_code=status.HTTP_400_BAD_REQUEST, detail="manifest hash not valid" 35 | ) 36 | manifest = get_manifest(crypto_hash, request.app.state.settings) 37 | return ManifestQueryResponse( 38 | manifests=[manifest], 39 | ) 40 | 41 | 42 | @router.put( 43 | "", 44 | response_model=ManifestSubmitResponse, 45 | tags=[MANIFEST], 46 | status_code=status.HTTP_202_ACCEPTED, 47 | ) 48 | def submit_manifest( 49 | request: Request, 50 | data: ValidateManifestRequest = Body(...), 51 | schema: Any = Depends(get_description_schema), 52 | ) -> ManifestSubmitResponse: 53 | """ 54 | Submit a manifest for storage. 55 | """ 56 | manifest, validation = _validate_manifest(data, schema) 57 | if not manifest or validation.status == ResponseStatus.FAIL: 58 | raise HTTPException( 59 | status_code=status.HTTP_400_BAD_REQUEST, detail=validation.details 60 | ) 61 | api_manifest = Manifest( 62 | manifest_hash=write_json_object(manifest.crypto_hash()), 63 | manifest=manifest.to_json_object(), 64 | ) 65 | return set_manifest(api_manifest, request.app.state.settings) 66 | 67 | 68 | @router.post("/find", response_model=ManifestQueryResponse, tags=[MANIFEST]) 69 | def find_manifests( 70 | request: Request, 71 | skip: int = 0, 72 | limit: int = 100, 73 | data: BaseQueryRequest = Body(...), 74 | ) -> ManifestQueryResponse: 75 | """ 76 | Find manifests. 77 | 78 | Search the repository for manifests that match the filter criteria specified in the request body. 79 | If no filter criteria is specified the API will iterate all available data. 80 | """ 81 | filter = write_json_object(data.filter) if data.filter else {} 82 | return filter_manifests(filter, skip, limit, request.app.state.settings) 83 | 84 | 85 | @router.post("/validate", response_model=ValidateManifestResponse, tags=[MANIFEST]) 86 | def validate_manifest( 87 | request: ValidateManifestRequest = Body(...), 88 | schema: Any = Depends(get_description_schema), 89 | ) -> ValidateManifestResponse: 90 | """ 91 | Validate an Election manifest for a given election. 92 | """ 93 | 94 | _, response = _validate_manifest(request, schema) 95 | return response 96 | 97 | 98 | def _deserialize_manifest(data: object) -> Optional[sdk_manifest]: 99 | try: 100 | return sdk_manifest.from_json_object(data) 101 | except Exception: # pylint: disable=broad-except 102 | # TODO: some sort of information why it failed 103 | return None 104 | 105 | 106 | def _validate_manifest( 107 | request: ValidateManifestRequest, schema: Any 108 | ) -> Tuple[Optional[sdk_manifest], ValidateManifestResponse]: 109 | # Check schema 110 | schema = request.schema_override if request.schema_override else schema 111 | (schema_success, schema_details) = validate_json_schema(request.manifest, schema) 112 | 113 | # Check object parse 114 | manifest = _deserialize_manifest(request.manifest) 115 | serialize_success = bool(manifest) 116 | valid_success = bool(serialize_success and get_optional(manifest).is_valid()) 117 | 118 | # build response 119 | success = schema_success and serialize_success and valid_success 120 | 121 | if success: 122 | return manifest, ValidateManifestResponse( 123 | message="Manifest successfully validated", 124 | manifest_hash=get_optional(manifest).crypto_hash().to_hex(), 125 | ) 126 | 127 | return manifest, ValidateManifestResponse( 128 | status=ResponseStatus.FAIL, 129 | message="Manifest failed validation", 130 | details=str( 131 | { 132 | "schema_success": schema_success, 133 | "serialize_success": serialize_success, 134 | "valid_success": valid_success, 135 | "schema_details": schema_details, 136 | } 137 | ), 138 | ) 139 | -------------------------------------------------------------------------------- /app/api/v1/mediator/routes.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | from . import ballot 3 | from . import decrypt 4 | from . import election 5 | from . import encrypt 6 | from . import key_admin 7 | from . import key_ceremony 8 | from . import key_guardian 9 | from . import manifest 10 | from . import tally 11 | from . import tally_decrypt 12 | 13 | router = APIRouter() 14 | 15 | router.include_router(key_guardian.router, prefix="/guardian") 16 | router.include_router(key_ceremony.router, prefix="/key") 17 | router.include_router(key_admin.router, prefix="/key") 18 | router.include_router(election.router, prefix="/election") 19 | router.include_router(manifest.router, prefix="/manifest") 20 | router.include_router(ballot.router, prefix="/ballot") 21 | router.include_router(decrypt.router, prefix="/ballot") 22 | router.include_router(encrypt.router, prefix="/ballot") 23 | router.include_router(tally.router, prefix="/tally") 24 | router.include_router(tally_decrypt.router, prefix="/tally/decrypt") 25 | -------------------------------------------------------------------------------- /app/api/v1/mediator/tally_decrypt.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Body, HTTPException, Request, status 2 | 3 | from electionguard.key_ceremony import PublicKeySet 4 | from electionguard.decryption_share import DecryptionShare 5 | from electionguard.serializable import read_json_object 6 | from electionguard.tally import CiphertextTallyContest 7 | from electionguard.utils import get_optional 8 | 9 | from app.core.election import get_election 10 | from app.core.key_guardian import get_key_guardian 11 | from app.core.tally import get_ciphertext_tally 12 | from app.core.tally_decrypt import ( 13 | get_decryption_share, 14 | set_decryption_share, 15 | filter_decryption_shares, 16 | ) 17 | from ..models import ( 18 | BaseResponse, 19 | DecryptionShareResponse, 20 | BaseQueryRequest, 21 | DecryptionShareRequest, 22 | ) 23 | from ..tags import TALLY_DECRYPT 24 | 25 | 26 | router = APIRouter() 27 | 28 | 29 | @router.get("", response_model=DecryptionShareResponse, tags=[TALLY_DECRYPT]) 30 | def fetch_decryption_share( 31 | request: Request, election_id: str, tally_name: str, guardian_id: str 32 | ) -> DecryptionShareResponse: 33 | """Get a decryption share for a specific tally for a specific guardian.""" 34 | share = get_decryption_share( 35 | election_id, tally_name, guardian_id, request.app.state.settings 36 | ) 37 | return DecryptionShareResponse( 38 | shares=[share], 39 | ) 40 | 41 | 42 | @router.post("/submit-share", response_model=BaseResponse, tags=[TALLY_DECRYPT]) 43 | def submit_share( 44 | request: Request, 45 | data: DecryptionShareRequest = Body(...), 46 | ) -> BaseResponse: 47 | """ 48 | Announce a guardian participating in a tally decryption by submitting a decryption share. 49 | """ 50 | 51 | election = get_election(data.share.election_id, request.app.state.settings) 52 | context = election.context.to_sdk_format() 53 | guardian = get_key_guardian( 54 | election.key_name, data.share.guardian_id, request.app.state.settings 55 | ) 56 | public_keys = read_json_object(get_optional(guardian.public_keys), PublicKeySet) 57 | 58 | api_tally = get_ciphertext_tally( 59 | data.share.election_id, data.share.tally_name, request.app.state.settings 60 | ) 61 | tally_share = read_json_object(data.share.tally_share, DecryptionShare) 62 | 63 | # TODO: spoiled ballot shares 64 | # ballot_shares = [ 65 | # read_json_object(ballot_share, DecryptionShare) 66 | # for ballot_share in data.share.ballot_shares 67 | # ] 68 | 69 | # validate the decryption share data matches the expectations in the tally 70 | # TODO: use the SDK for validation 71 | # sdk_tally = read_json_object(api_tally.tally, electionguard.tally.CiphertextTally) 72 | for contest_id, contest in api_tally.tally["contests"].items(): 73 | tally_contest = read_json_object(contest, CiphertextTallyContest) 74 | contest_share = tally_share.contests[contest_id] 75 | for selection_id, selection in tally_contest.selections.items(): 76 | selection_share = contest_share.selections[selection_id] 77 | if not selection_share.is_valid( 78 | selection.ciphertext, 79 | public_keys.election.key, 80 | context.crypto_extended_base_hash, 81 | ): 82 | raise HTTPException( 83 | status_code=status.HTTP_400_BAD_REQUEST, 84 | detail=f"decryption share failed valitation for contest: {contest_id} selection: {selection_id}", 85 | ) 86 | 87 | # TODO: validate spoiled ballot shares 88 | 89 | return set_decryption_share(data.share, request.app.state.settings) 90 | 91 | 92 | @router.post("/find", response_model=DecryptionShareResponse, tags=[TALLY_DECRYPT]) 93 | def find_decryption_shares( 94 | request: Request, 95 | tally_name: str, 96 | skip: int = 0, 97 | limit: int = 100, 98 | data: BaseQueryRequest = Body(...), 99 | ) -> DecryptionShareResponse: 100 | """Find descryption shares for a specific tally.""" 101 | shares = filter_decryption_shares( 102 | tally_name, data.filter, skip, limit, request.app.state.settings 103 | ) 104 | return DecryptionShareResponse( 105 | shares=shares, 106 | ) 107 | -------------------------------------------------------------------------------- /app/api/v1/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .auth import * 2 | from .ballot import * 3 | from .base import * 4 | from .decrypt import * 5 | from .encrypt import * 6 | from .election import * 7 | from .guardian import * 8 | from .key_ceremony import * 9 | from .key_guardian import * 10 | from .manifest import * 11 | from .tally import * 12 | from .tally_decrypt import * 13 | from .user import * 14 | -------------------------------------------------------------------------------- /app/api/v1/models/auth.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | from pydantic import BaseModel 4 | 5 | from app.api.v1.models.user import UserScope 6 | 7 | __all__ = [ 8 | "AuthenticationCredential", 9 | "Token", 10 | "TokenData", 11 | ] 12 | 13 | 14 | class AuthenticationCredential(BaseModel): 15 | """Authentication credential used to authenticate users.""" 16 | 17 | username: str 18 | hashed_password: str 19 | 20 | 21 | class Token(BaseModel): 22 | """An access token and its type.""" 23 | 24 | access_token: str 25 | token_type: str 26 | 27 | 28 | class TokenData(BaseModel): 29 | """The payload of an access token.""" 30 | 31 | username: Optional[str] = None 32 | scopes: List[UserScope] = [] 33 | 34 | 35 | class ErrorMessage(BaseModel): 36 | """Returns error messages to the client.""" 37 | 38 | detail: str 39 | -------------------------------------------------------------------------------- /app/api/v1/models/base.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional 2 | from enum import Enum 3 | from pydantic import BaseModel 4 | 5 | __all__ = [ 6 | "Base", 7 | "BaseRequest", 8 | "BaseResponse", 9 | "BaseQueryRequest", 10 | "BaseValidationRequest", 11 | "BaseValidationResponse", 12 | "ResponseStatus", 13 | ] 14 | 15 | Schema = Any 16 | 17 | 18 | class ResponseStatus(str, Enum): 19 | FAIL = "fail" 20 | SUCCESS = "success" 21 | 22 | 23 | class Base(BaseModel): 24 | "A basic model object" 25 | 26 | 27 | class BaseRequest(BaseModel): 28 | """A basic request""" 29 | 30 | 31 | class BaseResponse(BaseModel): 32 | """A basic response""" 33 | 34 | status: ResponseStatus = ResponseStatus.SUCCESS 35 | """The status of the response""" 36 | 37 | message: Optional[str] = None 38 | """An optional message describing the response""" 39 | 40 | def is_success(self) -> bool: 41 | return self.status == ResponseStatus.SUCCESS 42 | 43 | 44 | class BaseQueryRequest(BaseRequest): 45 | """Find something""" 46 | 47 | filter: Optional[Any] = None 48 | 49 | 50 | class BaseValidationRequest(BaseRequest): 51 | """Base validation request""" 52 | 53 | schema_override: Optional[Schema] = None 54 | """Optionally specify a schema to validate against""" 55 | 56 | 57 | class BaseValidationResponse(BaseResponse): 58 | """Response for validating models""" 59 | 60 | details: Optional[str] = None 61 | """Optional details of the validation result""" 62 | -------------------------------------------------------------------------------- /app/api/v1/models/decrypt.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List 2 | 3 | from .base import BaseRequest 4 | from .election import CiphertextElectionContextDto 5 | from .guardian import Guardian, GuardianId 6 | 7 | __all__ = [ 8 | "DecryptBallotSharesRequest", 9 | "DecryptBallotSharesResponse", 10 | "DecryptBallotsWithSharesRequest", 11 | ] 12 | 13 | DecryptionShare = Any 14 | SubmittedBallot = Any 15 | 16 | 17 | class DecryptBallotsWithSharesRequest(BaseRequest): 18 | """ 19 | Decrypt the provided ballots with the provided shares 20 | """ 21 | 22 | encrypted_ballots: List[SubmittedBallot] 23 | shares: Dict[GuardianId, List[DecryptionShare]] 24 | context: CiphertextElectionContextDto 25 | 26 | 27 | class DecryptBallotSharesRequest(BaseRequest): 28 | encrypted_ballots: List[SubmittedBallot] 29 | guardian: Guardian 30 | context: CiphertextElectionContextDto 31 | 32 | 33 | class DecryptBallotSharesResponse(BaseRequest): 34 | shares: List[DecryptionShare] = [] 35 | -------------------------------------------------------------------------------- /app/api/v1/models/election.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List, Optional 2 | from enum import Enum 3 | from electionguard.election import CiphertextElectionContext 4 | 5 | from app.api.v1.common.type_mapper import ( 6 | string_to_element_mod_p, 7 | string_to_element_mod_q, 8 | ) 9 | 10 | from .base import Base, BaseRequest, BaseResponse 11 | from .manifest import ElectionManifest 12 | 13 | 14 | __all__ = [ 15 | "Election", 16 | "ElectionState", 17 | "ElectionQueryRequest", 18 | "ElectionQueryResponse", 19 | "MakeElectionContextRequest", 20 | "MakeElectionContextResponse", 21 | "SubmitElectionRequest", 22 | ] 23 | 24 | 25 | class CiphertextElectionContextDto(Base): 26 | """The meta-data required for an election including keys, manifest, number of guardians, and quorum""" 27 | 28 | number_of_guardians: int 29 | """ 30 | The number of guardians necessary to generate the public key 31 | """ 32 | quorum: int 33 | """ 34 | The quorum of guardians necessary to decrypt an election. Must be less than `number_of_guardians` 35 | """ 36 | 37 | elgamal_public_key: str 38 | """the `joint public key (K)` in the [ElectionGuard Spec](https://github.com/microsoft/electionguard/wiki)""" 39 | 40 | commitment_hash: str 41 | """ 42 | the `commitment hash H(K 1,0 , K 2,0 ... , K n,0 )` of the public commitments 43 | guardians make to each other in the [ElectionGuard Spec](https://github.com/microsoft/electionguard/wiki) 44 | """ 45 | 46 | manifest_hash: str 47 | """The hash of the election metadata""" 48 | 49 | crypto_base_hash: str 50 | """The `base hash code (𝑄)` in the [ElectionGuard Spec](https://github.com/microsoft/electionguard/wiki)""" 51 | 52 | crypto_extended_base_hash: str 53 | """The `extended base hash code (𝑄')` in [ElectionGuard Spec](https://github.com/microsoft/electionguard/wiki)""" 54 | 55 | def to_sdk_format(self) -> CiphertextElectionContext: 56 | sdk_context = CiphertextElectionContext( 57 | self.number_of_guardians, 58 | self.quorum, 59 | string_to_element_mod_p(self.elgamal_public_key), 60 | string_to_element_mod_q(self.commitment_hash), 61 | string_to_element_mod_q(self.manifest_hash), 62 | string_to_element_mod_q(self.crypto_base_hash), 63 | string_to_element_mod_q(self.crypto_extended_base_hash), 64 | ) 65 | return sdk_context 66 | 67 | 68 | class ElectionState(str, Enum): 69 | CREATED = "CREATED" 70 | OPEN = "OPEN" 71 | CLOSED = "CLOSED" 72 | PUBLISHED = "PUBLISHED" 73 | 74 | 75 | class Election(Base): 76 | """An election object.""" 77 | 78 | def get_name(self) -> str: 79 | text = self.manifest["name"]["text"] 80 | # todo: replace "en" with user's current culture 81 | enText = [t["value"] for t in text if t["language"] == "en"] 82 | return str(enText[0]) 83 | 84 | election_id: str 85 | key_name: str 86 | state: ElectionState 87 | context: CiphertextElectionContextDto 88 | manifest: ElectionManifest 89 | 90 | 91 | class ElectionQueryRequest(BaseRequest): 92 | """A request for elections using the specified filter.""" 93 | 94 | filter: Optional[Any] = None 95 | """ 96 | a json object filter that will be applied to the search. 97 | """ 98 | 99 | class Config: 100 | schema_extra = {"example": {"filter": {"election_id": "some-election-id-123"}}} 101 | 102 | 103 | class ElectionQueryResponse(BaseResponse): 104 | """A collection of elections.""" 105 | 106 | elections: List[Election] = [] 107 | 108 | 109 | class ElectionSummaryDto(Base): 110 | election_id: str 111 | name: Any 112 | state: str 113 | number_of_guardians: int 114 | quorum: int 115 | cast_ballot_count: int 116 | spoiled_ballot_count: int 117 | index: int 118 | 119 | 120 | class ElectionListResponseDto(BaseResponse): 121 | """A collection of elections.""" 122 | 123 | elections: List[ElectionSummaryDto] = [] 124 | 125 | 126 | class SubmitElectionRequest(BaseRequest): 127 | """Submit an election.""" 128 | 129 | election_id: str 130 | key_name: str 131 | context: CiphertextElectionContextDto 132 | manifest: Optional[ElectionManifest] = None 133 | 134 | 135 | class MakeElectionContextRequest(BaseRequest): 136 | """ 137 | A request to build an Election Context for a given election. 138 | """ 139 | 140 | elgamal_public_key: str 141 | commitment_hash: str 142 | number_of_guardians: int 143 | quorum: int 144 | manifest_hash: Optional[str] = None 145 | manifest: Optional[ElectionManifest] = None 146 | 147 | 148 | class MakeElectionContextResponse(BaseResponse): 149 | """A Ciphertext Election Context.""" 150 | 151 | context: CiphertextElectionContextDto 152 | -------------------------------------------------------------------------------- /app/api/v1/models/encrypt.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List 2 | 3 | from .base import BaseRequest, BaseResponse 4 | 5 | 6 | __all__ = [ 7 | "EncryptBallotsRequest", 8 | "EncryptBallotsResponse", 9 | ] 10 | 11 | CiphertextBallot = Any 12 | PlaintextBallot = Any 13 | 14 | 15 | class EncryptBallotsRequest(BaseRequest): 16 | """A request to encrypt the enclosed ballots.""" 17 | 18 | election_id: str 19 | seed_hash: str 20 | ballots: List[PlaintextBallot] 21 | 22 | 23 | class EncryptBallotsResponse(BaseResponse): 24 | encrypted_ballots: List[CiphertextBallot] 25 | """The encrypted representations of the plaintext ballots.""" 26 | next_seed_hash: str 27 | """A seed hash which can optionally be used for the next call to encrypt.""" 28 | -------------------------------------------------------------------------------- /app/api/v1/models/guardian.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List, Optional 2 | 3 | import electionguard.auxiliary 4 | import electionguard.election_polynomial 5 | import electionguard.elgamal 6 | import electionguard.group 7 | import electionguard.guardian 8 | import electionguard.key_ceremony 9 | import electionguard.schnorr 10 | from electionguard.serializable import read_json_object 11 | from electionguard.type import GUARDIAN_ID 12 | 13 | from .base import Base, BaseRequest, BaseResponse 14 | 15 | __all__ = [ 16 | "ElectionPolynomial", 17 | "ElectionPartialKeyBackup", 18 | "ElectionPartialKeyChallenge", 19 | "Guardian", 20 | "CreateGuardianRequest", 21 | "GuardianPublicKeysResponse", 22 | "GuardianBackupRequest", 23 | "GuardianBackupResponse", 24 | "BackupVerificationRequest", 25 | "BackupVerificationResponse", 26 | "BackupChallengeRequest", 27 | "BackupChallengeResponse", 28 | "ChallengeVerificationRequest", 29 | "to_sdk_guardian", 30 | "ApiGuardianQueryResponse", 31 | ] 32 | 33 | ElectionPolynomial = Any 34 | ElectionPartialKeyBackup = Any 35 | ElectionPartialKeyChallenge = Any 36 | ElectionPartialKeyVerification = Any 37 | GuardianId = Any 38 | 39 | ElectionKeyPair = Any 40 | AuxiliaryKeyPair = Any 41 | 42 | AuxiliaryPublicKey = Any 43 | ElectionPublicKey = Any 44 | 45 | PublicKeySet = Any 46 | 47 | 48 | class Guardian(Base): 49 | """The API guardian tracks the state of a guardain's interactions with other guardians.""" 50 | 51 | guardian_id: str 52 | name: str 53 | sequence_order: int 54 | number_of_guardians: int 55 | quorum: int 56 | election_keys: ElectionKeyPair 57 | auxiliary_keys: AuxiliaryKeyPair 58 | backups: Dict[GUARDIAN_ID, ElectionPartialKeyBackup] = {} 59 | cohort_public_keys: Dict[GUARDIAN_ID, PublicKeySet] = {} 60 | cohort_backups: Dict[GUARDIAN_ID, ElectionPartialKeyBackup] = {} 61 | cohort_verifications: Dict[GUARDIAN_ID, ElectionPartialKeyVerification] = {} 62 | cohort_challenges: Dict[GUARDIAN_ID, ElectionPartialKeyChallenge] = {} 63 | 64 | 65 | class CreateGuardianRequest(BaseRequest): 66 | """Request to create a Guardain.""" 67 | 68 | guardian_id: str 69 | name: Optional[str] = None 70 | sequence_order: int 71 | number_of_guardians: int 72 | quorum: int 73 | nonce: Optional[str] = None 74 | auxiliary_key_pair: Optional[AuxiliaryKeyPair] = None 75 | 76 | 77 | class CreateElectionKeyPairRequest(BaseRequest): 78 | """Request to create an Election Key Pair.""" 79 | 80 | owner_id: str 81 | sequence_order: int 82 | quorum: int 83 | nonce: Optional[str] = None 84 | 85 | 86 | class CreateElectionKeyPairResponse(BaseResponse): 87 | """Returns an ElectionKeyPair.""" 88 | 89 | election_key_pair: ElectionKeyPair 90 | 91 | 92 | class CreateAuxiliaryKeyPairRequest(BaseRequest): 93 | """Request to create an AuxiliaryKeyPair.""" 94 | 95 | owner_id: str 96 | sequence_order: int 97 | 98 | 99 | class CreateAuxiliaryKeyPairResponse(BaseResponse): 100 | """Returns an AuxiliaryKeyPair.""" 101 | 102 | auxiliary_key_pair: AuxiliaryKeyPair 103 | 104 | 105 | class GuardianPublicKeysResponse(BaseResponse): 106 | """Returns a set of public auxiliary and election keys.""" 107 | 108 | public_keys: PublicKeySet 109 | 110 | 111 | class GuardianBackupRequest(BaseRequest): 112 | """Request to generate ElectionPartialKeyBackups for the given PublicKeySets.""" 113 | 114 | guardian_id: str 115 | quorum: int 116 | public_keys: List[PublicKeySet] 117 | override_rsa: bool = False 118 | 119 | 120 | class GuardianBackupResponse(BaseResponse): 121 | """Returns a collection of ElectionPartialKeyBackups to be shared with other guardians.""" 122 | 123 | guardian_id: str 124 | backups: List[ElectionPartialKeyBackup] 125 | 126 | 127 | class BackupVerificationRequest(BaseRequest): 128 | """Request to verify the associated backups shared with the guardian.""" 129 | 130 | guardian_id: str 131 | backup: ElectionPartialKeyBackup 132 | override_rsa: bool = False 133 | 134 | 135 | class BackupVerificationResponse(BaseResponse): 136 | """Returns a collection of verifications.""" 137 | 138 | verification: ElectionPartialKeyVerification 139 | 140 | 141 | class BackupChallengeRequest(BaseRequest): 142 | """Request to challenge a specific backup.""" 143 | 144 | guardian_id: str 145 | backup: ElectionPartialKeyBackup 146 | 147 | 148 | class BackupChallengeResponse(BaseResponse): 149 | """Returns a challenge to a given backup.""" 150 | 151 | challenge: ElectionPartialKeyChallenge 152 | 153 | 154 | class ChallengeVerificationRequest(BaseRequest): 155 | """Request to verify a challenge.""" 156 | 157 | verifier_id: str 158 | challenge: ElectionPartialKeyChallenge 159 | 160 | 161 | class ApiGuardianQueryResponse(BaseResponse): 162 | """Returns a collection of KeyCeremonyGuardians.""" 163 | 164 | guardians: List[Guardian] 165 | 166 | 167 | # pylint:disable=protected-access 168 | def to_sdk_guardian(api_guardian: Guardian) -> electionguard.guardian.Guardian: 169 | """ 170 | Convert an API Guardian model to a fully-hydrated SDK Guardian model. 171 | """ 172 | 173 | guardian = electionguard.guardian.Guardian( 174 | api_guardian.guardian_id, 175 | api_guardian.sequence_order, 176 | api_guardian.number_of_guardians, 177 | api_guardian.quorum, 178 | ) 179 | 180 | guardian._auxiliary_keys = read_json_object( 181 | api_guardian.auxiliary_keys, electionguard.auxiliary.AuxiliaryKeyPair 182 | ) 183 | guardian._election_keys = read_json_object( 184 | api_guardian.election_keys, electionguard.key_ceremony.ElectionKeyPair 185 | ) 186 | 187 | cohort_keys = { 188 | owner_id: read_json_object(key_set, electionguard.key_ceremony.PublicKeySet) 189 | for (owner_id, key_set) in api_guardian.cohort_public_keys.items() 190 | } 191 | 192 | guardian._guardian_auxiliary_public_keys = { 193 | owner_id: key_set.auxiliary for (owner_id, key_set) in cohort_keys.items() 194 | } 195 | 196 | guardian._guardian_election_public_keys = { 197 | owner_id: key_set.election for (owner_id, key_set) in cohort_keys.items() 198 | } 199 | 200 | guardian._guardian_election_partial_key_backups = { 201 | owner_id: read_json_object( 202 | backup, electionguard.key_ceremony.ElectionPartialKeyBackup 203 | ) 204 | for (owner_id, backup) in api_guardian.cohort_backups.items() 205 | } 206 | 207 | guardian._guardian_election_partial_key_verifications = { 208 | owner_id: read_json_object( 209 | verification, electionguard.key_ceremony.ElectionPartialKeyVerification 210 | ) 211 | for (owner_id, verification) in api_guardian.cohort_verifications.items() 212 | } 213 | 214 | return guardian 215 | -------------------------------------------------------------------------------- /app/api/v1/models/key_ceremony.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List, Optional 2 | from enum import Enum 3 | 4 | from electionguard.type import GUARDIAN_ID 5 | 6 | from .base import Base, BaseRequest, BaseResponse 7 | from .key_guardian import KeyCeremonyGuardianState, ElectionPartialKeyVerification 8 | 9 | __all__ = [ 10 | "KeyCeremony", 11 | "KeyCeremonyState", 12 | "KeyCeremonyCreateRequest", 13 | "KeyCeremonyStateResponse", 14 | "KeyCeremonyQueryResponse", 15 | "KeyCeremonyVerifyChallengesResponse", 16 | "PublishElectionJointKeyRequest", 17 | "ElectionJointKeyResponse", 18 | ] 19 | 20 | ElectionPublicKey = Any 21 | ElGamalKeyPair = Any 22 | 23 | ElectionJointKey = Any 24 | ElementModQ = Any 25 | 26 | 27 | class KeyCeremonyState(str, Enum): 28 | """Enumeration expressing the state of the key caremony.""" 29 | 30 | CREATED = "CREATED" 31 | OPEN = "OPEN" 32 | CLOSED = "CLOSED" 33 | CHALLENGED = "CHALLENGED" 34 | CANCELLED = "CANCELLED" 35 | 36 | 37 | class KeyCeremony(Base): 38 | """The Key Ceremony is a record of the state of a key ceremony.""" 39 | 40 | key_name: str 41 | state: KeyCeremonyState 42 | number_of_guardians: int 43 | quorum: int 44 | guardian_ids: List[GUARDIAN_ID] 45 | guardian_status: Dict[GUARDIAN_ID, KeyCeremonyGuardianState] 46 | elgamal_public_key: Optional[ElectionJointKey] = None 47 | commitment_hash: Optional[ElementModQ] = None 48 | 49 | 50 | class KeyCeremonyStateResponse(Base): 51 | """Returns a subset of KeyCeremony data that describes only the state.""" 52 | 53 | key_name: str 54 | state: KeyCeremonyState 55 | guardian_status: Dict[GUARDIAN_ID, KeyCeremonyGuardianState] 56 | 57 | 58 | class KeyCeremonyQueryResponse(BaseResponse): 59 | """Returns a collection of Key Ceremonies.""" 60 | 61 | key_ceremonies: List[KeyCeremony] 62 | 63 | 64 | class KeyCeremonyVerifyChallengesResponse(BaseResponse): 65 | """Returns a collection of Partial Key Verifications.""" 66 | 67 | verifications: List[ElectionPartialKeyVerification] 68 | 69 | 70 | class KeyCeremonyCreateRequest(BaseRequest): 71 | """Request to create a new key ceremony.""" 72 | 73 | key_name: str 74 | number_of_guardians: int 75 | quorum: int 76 | guardian_ids: List[str] 77 | 78 | 79 | class PublishElectionJointKeyRequest(BaseRequest): 80 | """Request to publish the election joint key.""" 81 | 82 | key_name: str 83 | election_public_keys: List[ElectionPublicKey] 84 | 85 | 86 | class ElectionJointKeyResponse(BaseResponse): 87 | """Response object containing the Election Joint Key.""" 88 | 89 | elgamal_public_key: ElectionJointKey 90 | commitment_hash: ElementModQ 91 | -------------------------------------------------------------------------------- /app/api/v1/models/key_guardian.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List, Optional 2 | from enum import Enum 3 | 4 | from electionguard.type import GUARDIAN_ID 5 | 6 | from .base import Base, BaseRequest, BaseResponse 7 | 8 | 9 | __all__ = [ 10 | "GuardianAnnounceRequest", 11 | "GuardianSubmitBackupRequest", 12 | "GuardianQueryResponse", 13 | "GuardianSubmitVerificationRequest", 14 | "GuardianSubmitChallengeRequest", 15 | "KeyCeremonyGuardian", 16 | "KeyCeremonyGuardianStatus", 17 | "KeyCeremonyGuardianState", 18 | ] 19 | 20 | PublicKeySet = Any 21 | 22 | ElectionPartialKeyBackup = Any 23 | ElectionPartialKeyVerification = Any 24 | ElectionPartialKeyChallenge = Any 25 | 26 | 27 | class KeyCeremonyGuardianStatus(str, Enum): 28 | """Enumeration expressing the status of a guardian's operations.""" 29 | 30 | INCOMPLETE = "INCOMPLETE" 31 | ERROR = "ERROR" 32 | COMPLETE = "COMPLETE" 33 | 34 | 35 | class KeyCeremonyGuardianState(Base): 36 | """Describes the operations each guardian must fulfill to complete a key ceremony.""" 37 | 38 | public_key_shared: KeyCeremonyGuardianStatus = KeyCeremonyGuardianStatus.INCOMPLETE 39 | backups_shared: KeyCeremonyGuardianStatus = KeyCeremonyGuardianStatus.INCOMPLETE 40 | backups_verified: KeyCeremonyGuardianStatus = KeyCeremonyGuardianStatus.INCOMPLETE 41 | 42 | 43 | class KeyCeremonyGuardian(Base): 44 | """ 45 | A record of the public data exchanged between guardians. 46 | """ 47 | 48 | key_name: str 49 | guardian_id: GUARDIAN_ID 50 | name: str 51 | sequence_order: int 52 | number_of_guardians: int 53 | quorum: int 54 | public_keys: Optional[PublicKeySet] = None 55 | backups: List[ElectionPartialKeyBackup] = [] 56 | verifications: List[ElectionPartialKeyVerification] = [] 57 | challenges: List[ElectionPartialKeyChallenge] = [] 58 | 59 | 60 | class GuardianAnnounceRequest(BaseRequest): 61 | """A set of public auxiliary and election keys.""" 62 | 63 | key_name: str 64 | """The Key Ceremony to announce""" 65 | public_keys: PublicKeySet 66 | 67 | 68 | class GuardianQueryResponse(BaseResponse): 69 | """Returns a collection of KeyCeremonyGuardians.""" 70 | 71 | guardians: List[KeyCeremonyGuardian] 72 | 73 | 74 | class GuardianSubmitBackupRequest(BaseRequest): 75 | """Submit a collection of backups for a guardian.""" 76 | 77 | key_name: str 78 | guardian_id: str 79 | backups: List[ElectionPartialKeyBackup] 80 | 81 | 82 | class GuardianSubmitVerificationRequest(BaseRequest): 83 | """Submit a collection of verifications for a guardian.""" 84 | 85 | key_name: str 86 | guardian_id: str 87 | verifications: List[ElectionPartialKeyVerification] 88 | 89 | 90 | class GuardianSubmitChallengeRequest(BaseRequest): 91 | """Submit a collection of challenges for a guardian.""" 92 | 93 | key_name: str 94 | guardian_id: str 95 | challenges: List[ElectionPartialKeyChallenge] 96 | -------------------------------------------------------------------------------- /app/api/v1/models/manifest.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List, Optional 2 | 3 | from .base import ( 4 | Base, 5 | BaseResponse, 6 | BaseValidationRequest, 7 | BaseValidationResponse, 8 | ) 9 | 10 | __all__ = [ 11 | "Manifest", 12 | "ManifestSubmitResponse", 13 | "ManifestQueryResponse", 14 | "ValidateManifestRequest", 15 | "ValidateManifestResponse", 16 | ] 17 | 18 | ElectionManifest = Any 19 | ElementModQ = Any 20 | 21 | 22 | class Manifest(Base): 23 | manifest_hash: ElementModQ 24 | manifest: ElectionManifest 25 | 26 | 27 | class ManifestSubmitResponse(BaseResponse): 28 | manifest_hash: ElementModQ 29 | 30 | 31 | class ManifestQueryResponse(BaseResponse): 32 | manifests: List[Manifest] 33 | 34 | 35 | class ValidateManifestRequest(BaseValidationRequest): 36 | """ 37 | A request to validate an Election Description. 38 | """ 39 | 40 | manifest: ElectionManifest 41 | """The manifest to validate.""" 42 | 43 | 44 | class ValidateManifestResponse(BaseValidationResponse): 45 | manifest_hash: Optional[str] = None 46 | -------------------------------------------------------------------------------- /app/api/v1/models/tally.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List, Optional 2 | from enum import Enum 3 | from datetime import datetime 4 | 5 | from .base import BaseResponse, BaseRequest, Base 6 | 7 | __all__ = [ 8 | "CiphertextTally", 9 | "CiphertextTallyQueryResponse", 10 | "DecryptTallyRequest", 11 | "PlaintextTally", 12 | "PlaintextTallyState", 13 | "PlaintextTallyQueryResponse", 14 | ] 15 | 16 | ElectionGuardCiphertextTally = Any 17 | ElectionGuardPlaintextTally = Any 18 | 19 | 20 | class CiphertextTally(Base): 21 | """A Tally for a specific election.""" 22 | 23 | election_id: str 24 | tally_name: str 25 | created: datetime 26 | tally: ElectionGuardCiphertextTally 27 | """The full electionguard CiphertextTally that includes the cast and spoiled ballot id's.""" 28 | 29 | 30 | class PlaintextTallyState(str, Enum): 31 | CREATED = "CREATED" 32 | PROCESSING = "PROCESSING" 33 | ERROR = "ERROR" 34 | COMPLETE = "COMPLETE" 35 | 36 | 37 | class PlaintextTally(Base): 38 | """A plaintext tally for a specific election.""" 39 | 40 | election_id: str 41 | tally_name: str 42 | created: datetime 43 | state: PlaintextTallyState 44 | tally: Optional[ElectionGuardPlaintextTally] = None 45 | 46 | 47 | class CiphertextTallyQueryResponse(BaseResponse): 48 | """A collection of Ciphertext Tallies.""" 49 | 50 | tallies: List[CiphertextTally] = [] 51 | 52 | 53 | class PlaintextTallyQueryResponse(BaseResponse): 54 | """A collection of Plaintext Tallies.""" 55 | 56 | tallies: List[PlaintextTally] = [] 57 | 58 | 59 | class DecryptTallyRequest(BaseRequest): 60 | """A request to decrypt a specific tally. Can optionally include the tally to decrypt.""" 61 | 62 | election_id: str 63 | tally_name: str 64 | -------------------------------------------------------------------------------- /app/api/v1/models/tally_decrypt.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List 2 | 3 | from electionguard.type import BALLOT_ID 4 | 5 | from .base import BaseResponse, BaseRequest, Base 6 | from .election import CiphertextElectionContextDto 7 | from .tally import CiphertextTally 8 | 9 | __all__ = [ 10 | "CiphertextTallyDecryptionShare", 11 | "DecryptTallyShareRequest", 12 | "DecryptionShareRequest", 13 | "DecryptionShareResponse", 14 | ] 15 | 16 | DecryptionShare = Any 17 | ElectionGuardCiphertextTally = Any 18 | 19 | 20 | class CiphertextTallyDecryptionShare(Base): 21 | """ 22 | A DecryptionShare provided by a guardian for a specific tally. 23 | 24 | Optionally can include ballot_shares for challenge ballots. 25 | """ 26 | 27 | election_id: str # TODO: not needed since we have the tally_name? 28 | tally_name: str 29 | guardian_id: str 30 | tally_share: DecryptionShare 31 | """The EG Decryptionshare that includes a share for each contest in the election.""" 32 | ballot_shares: Dict[BALLOT_ID, DecryptionShare] = {} 33 | """A collection of shares for each challenge ballot.""" 34 | 35 | 36 | class DecryptTallyShareRequest(BaseRequest): 37 | """A request to partially decrypt a tally and generate a DecryptionShare.""" 38 | 39 | guardian_id: str 40 | encrypted_tally: CiphertextTally 41 | context: CiphertextElectionContextDto 42 | 43 | 44 | class DecryptionShareRequest(BaseRequest): 45 | """A request to submit a decryption share.""" 46 | 47 | share: CiphertextTallyDecryptionShare 48 | 49 | 50 | class DecryptionShareResponse(BaseResponse): 51 | """A response that includes a collection of decryption shares.""" 52 | 53 | shares: List[CiphertextTallyDecryptionShare] 54 | -------------------------------------------------------------------------------- /app/api/v1/models/user.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List, Optional 2 | from enum import Enum 3 | 4 | from pydantic import BaseModel 5 | 6 | from .base import BaseRequest, BaseResponse 7 | 8 | __all__ = [ 9 | "UserScope", 10 | "UserInfo", 11 | ] 12 | 13 | 14 | class UserScope(str, Enum): 15 | admin = "admin" 16 | """The admin role can execute administrative functions.""" 17 | auditor = "auditor" 18 | """The auditor role is a readonly role that can observe the election.""" 19 | guardian = "guardian" 20 | """The guardian role can excute guardian functions.""" 21 | voter = "voter" 22 | """ 23 | The voter role can execute voting functions such as encrypt ballot. 24 | The voting endpoints are useful for testing only and are not recommended for production. 25 | """ 26 | 27 | 28 | class UserInfo(BaseModel): 29 | """A specific user in the system""" 30 | 31 | username: str 32 | first_name: str 33 | last_name: str 34 | scopes: List[UserScope] = [] 35 | email: Optional[str] = None 36 | disabled: Optional[bool] = None 37 | 38 | 39 | class CreateUserResponse(BaseResponse): 40 | user_info: UserInfo 41 | password: str 42 | 43 | 44 | class UserQueryRequest(BaseRequest): 45 | """A request for users using the specified filter.""" 46 | 47 | filter: Optional[Any] = None 48 | """ 49 | a json object filter that will be applied to the search. Leave empty to retrieve all users. 50 | """ 51 | 52 | class Config: 53 | schema_extra = {"example": {"filter": {"name": "Jane Doe"}}} 54 | 55 | 56 | class UserQueryResponse(BaseModel): 57 | """Returns a collection of Users.""" 58 | 59 | users: List[UserInfo] 60 | -------------------------------------------------------------------------------- /app/api/v1/routes.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | from app.core.settings import ApiMode, Settings 3 | from . import common 4 | from . import guardian 5 | from . import mediator 6 | from . import auth 7 | 8 | 9 | def get_v1_routes(settings: Settings) -> APIRouter: 10 | api_router = APIRouter() 11 | 12 | api_router.include_router(auth.router) 13 | 14 | if settings.API_MODE == ApiMode.GUARDIAN: 15 | api_router.include_router(guardian.router) 16 | elif settings.API_MODE == ApiMode.MEDIATOR: 17 | api_router.include_router(mediator.router) 18 | else: 19 | raise ValueError(f"Unknown API mode: {settings.API_MODE}") 20 | 21 | api_router.include_router(common.router) 22 | 23 | return api_router 24 | -------------------------------------------------------------------------------- /app/api/v1/tags.py: -------------------------------------------------------------------------------- 1 | AUTHORIZE = "Authentication & Authorization" 2 | ELECTION = "Configure Election" 3 | MANIFEST = "Election Manifest" 4 | GUARDIAN = "Guardian" 5 | KEY_CEREMONY = "Key Ceremony" 6 | KEY_CEREMONY_ADMIN = "Key Ceremony Admin" 7 | KEY_GUARDIAN = "Key Ceremony Participant" 8 | BALLOTS = "Query Ballots" 9 | ENCRYPT = "Encrypt Ballots" 10 | TALLY = "Tally Results" 11 | TALLY_DECRYPT = "Tally Decrypt" 12 | PUBLISH = "Publish Results" 13 | UTILITY = "Utility Functions" 14 | USER = "User Information" 15 | -------------------------------------------------------------------------------- /app/api/v1_1/common/__init__.py: -------------------------------------------------------------------------------- 1 | from .routes import router 2 | -------------------------------------------------------------------------------- /app/api/v1_1/common/ping.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from fastapi import APIRouter 4 | 5 | from ..tags import UTILITY 6 | 7 | router = APIRouter() 8 | 9 | 10 | @router.get("", response_model=str, tags=[UTILITY]) 11 | def ping() -> Any: 12 | """ 13 | Ensure API can be pinged 14 | """ 15 | return "pong" 16 | -------------------------------------------------------------------------------- /app/api/v1_1/common/routes.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | from . import ping 3 | 4 | router = APIRouter() 5 | 6 | router.include_router(ping.router, prefix="/ping") 7 | -------------------------------------------------------------------------------- /app/api/v1_1/guardian/__init__.py: -------------------------------------------------------------------------------- 1 | from .routes import router 2 | -------------------------------------------------------------------------------- /app/api/v1_1/guardian/ping.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from fastapi import APIRouter 4 | 5 | from ..tags import UTILITY 6 | 7 | router = APIRouter() 8 | 9 | 10 | @router.get("", response_model=str, tags=[UTILITY]) 11 | def ping() -> Any: 12 | """ 13 | Ensure API can be pinged 14 | """ 15 | return "pong" 16 | -------------------------------------------------------------------------------- /app/api/v1_1/guardian/routes.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | from . import ping 3 | 4 | router = APIRouter() 5 | 6 | router.include_router(ping.router, prefix="/ping") 7 | -------------------------------------------------------------------------------- /app/api/v1_1/mediator/__init__.py: -------------------------------------------------------------------------------- 1 | from .routes import router 2 | -------------------------------------------------------------------------------- /app/api/v1_1/mediator/election.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Body, Request 2 | 3 | from ..models import ( 4 | BaseResponse, 5 | CreateElectionRequest, 6 | ) 7 | from ..tags import ELECTION 8 | 9 | router = APIRouter() 10 | 11 | 12 | @router.put("", response_model=BaseResponse, tags=[ELECTION]) 13 | def create_election( 14 | request: Request, 15 | data: CreateElectionRequest = Body(...), 16 | ) -> BaseResponse: 17 | """ 18 | 1. Create an election 19 | 20 | Takes only an optional name parameter and returns a surrogate key so that 21 | users can subsequently add a manifest and key to it prior to opening the 22 | election. 23 | """ 24 | 25 | return BaseResponse( 26 | message=f"This endpoint isn't yet implemented, but eventually it will add the '{data.name}' election", 27 | ) 28 | -------------------------------------------------------------------------------- /app/api/v1_1/mediator/routes.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | from . import election 3 | 4 | router = APIRouter() 5 | 6 | router.include_router(election.router, prefix="/election") 7 | -------------------------------------------------------------------------------- /app/api/v1_1/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .election import * 2 | from .base import * 3 | -------------------------------------------------------------------------------- /app/api/v1_1/models/base.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional 2 | from enum import Enum 3 | from pydantic import BaseModel 4 | 5 | __all__ = [ 6 | "Base", 7 | "BaseRequest", 8 | "BaseResponse", 9 | "BaseQueryRequest", 10 | "BaseValidationRequest", 11 | "BaseValidationResponse", 12 | "ResponseStatus", 13 | ] 14 | 15 | Schema = Any 16 | 17 | 18 | class ResponseStatus(str, Enum): 19 | FAIL = "fail" 20 | SUCCESS = "success" 21 | 22 | 23 | class Base(BaseModel): 24 | "A basic model object" 25 | 26 | 27 | class BaseRequest(BaseModel): 28 | """A basic request""" 29 | 30 | 31 | class BaseResponse(BaseModel): 32 | """A basic response""" 33 | 34 | status: ResponseStatus = ResponseStatus.SUCCESS 35 | """The status of the response""" 36 | 37 | message: Optional[str] = None 38 | """An optional message describing the response""" 39 | 40 | def is_success(self) -> bool: 41 | return self.status == ResponseStatus.SUCCESS 42 | 43 | 44 | class BaseQueryRequest(BaseRequest): 45 | """Find something""" 46 | 47 | filter: Optional[Any] = None 48 | 49 | 50 | class BaseValidationRequest(BaseRequest): 51 | """Base validation request""" 52 | 53 | schema_override: Optional[Schema] = None 54 | """Optionally specify a schema to validate against""" 55 | 56 | 57 | class BaseValidationResponse(BaseResponse): 58 | """Response for validating models""" 59 | 60 | details: Optional[str] = None 61 | """Optional details of the validation result""" 62 | -------------------------------------------------------------------------------- /app/api/v1_1/models/election.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from .base import BaseRequest 4 | 5 | 6 | __all__ = [ 7 | "CreateElectionRequest", 8 | ] 9 | 10 | AnyCiphertextElectionContext = Any 11 | 12 | 13 | class CreateElectionRequest(BaseRequest): 14 | """Create an election.""" 15 | 16 | name: str 17 | -------------------------------------------------------------------------------- /app/api/v1_1/routes.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | from app.api.v1 import auth 3 | from app.core.settings import ApiMode, Settings 4 | from . import common 5 | from . import mediator 6 | from . import guardian 7 | 8 | 9 | def get_v1_1_routes(settings: Settings) -> APIRouter: 10 | api_router = APIRouter() 11 | 12 | if settings.API_MODE == ApiMode.GUARDIAN: 13 | api_router.include_router(guardian.router) 14 | elif settings.API_MODE == ApiMode.MEDIATOR: 15 | api_router.include_router(mediator.router) 16 | else: 17 | raise ValueError(f"Unknown API mode: {settings.API_MODE}") 18 | 19 | api_router.include_router(common.router) 20 | 21 | return api_router 22 | -------------------------------------------------------------------------------- /app/api/v1_1/tags.py: -------------------------------------------------------------------------------- 1 | AUTHORIZE = "Authentication & Authorization" 2 | ELECTION = "Configure Election" 3 | MANIFEST = "Election Manifest" 4 | GUARDIAN = "Guardian" 5 | KEY_CEREMONY = "Key Ceremony" 6 | KEY_CEREMONY_ADMIN = "Key Ceremony Admin" 7 | KEY_GUARDIAN = "Key Ceremony Participant" 8 | BALLOTS = "Query Ballots" 9 | ENCRYPT = "Encrypt Ballots" 10 | TALLY = "Tally Results" 11 | TALLY_DECRYPT = "Tally Decrypt" 12 | PUBLISH = "Publish Results" 13 | UTILITY = "Utility Functions" 14 | USER = "User Information" 15 | -------------------------------------------------------------------------------- /app/core/__init__.py: -------------------------------------------------------------------------------- 1 | from .auth import * 2 | from .ballot import * 3 | from .client import * 4 | from .election import * 5 | from .guardian import * 6 | from .key_ceremony import * 7 | from .key_guardian import * 8 | from .manifest import * 9 | from .queue import * 10 | from .repository import * 11 | from .scheduler import * 12 | from .schema import * 13 | from .settings import * 14 | from .tally_decrypt import * 15 | from .tally import * 16 | from .user import * 17 | -------------------------------------------------------------------------------- /app/core/auth.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from typing import Any, Union 3 | from fastapi import HTTPException, status 4 | 5 | from passlib.context import CryptContext 6 | 7 | from .client import get_client_id 8 | from .repository import get_repository, DataCollection 9 | from .settings import Settings 10 | from ..api.v1.models import BaseResponse, AuthenticationCredential 11 | 12 | __all__ = [ 13 | "AuthenticationContext", 14 | "get_auth_credential", 15 | "set_auth_credential", 16 | "update_auth_credential", 17 | ] 18 | 19 | 20 | class AuthenticationContext: 21 | """ 22 | An Authentication context object wrapper 23 | encapsulating the crypto context used to verify crdentials 24 | """ 25 | 26 | def __init__(self, settings: Settings = Settings()): 27 | self.context = CryptContext(schemes=["bcrypt"], deprecated="auto") 28 | self.settings = settings 29 | 30 | def authenticate_credential(self, username: str, password: str) -> Any: 31 | credential = get_auth_credential(username, self.settings) 32 | return self.verify_password(password, credential.hashed_password) 33 | 34 | def verify_password(self, plain_password: str, hashed_password: str) -> Any: 35 | return self.context.verify(plain_password, hashed_password) 36 | 37 | def get_password_hash(self, password: Union[bytes, str]) -> Any: 38 | return self.context.hash(password) 39 | 40 | 41 | def get_auth_credential( 42 | username: str, settings: Settings = Settings() 43 | ) -> AuthenticationCredential: 44 | """Get an authenitcation credential from the repository.""" 45 | try: 46 | with get_repository( 47 | get_client_id(), DataCollection.AUTHENTICATION, settings 48 | ) as repository: 49 | query_result = repository.get({"username": username}) 50 | if not query_result: 51 | raise HTTPException( 52 | status_code=status.HTTP_404_NOT_FOUND, 53 | detail=f"Could not find credential for {username}", 54 | ) 55 | return AuthenticationCredential(**query_result) 56 | except Exception as error: 57 | print(sys.exc_info()) 58 | raise HTTPException( 59 | status_code=status.HTTP_404_NOT_FOUND, 60 | detail=f"{username} not found", 61 | ) from error 62 | 63 | 64 | def set_auth_credential( 65 | credential: AuthenticationCredential, settings: Settings = Settings() 66 | ) -> None: 67 | """Set an authentication credential in the repository.""" 68 | try: 69 | with get_repository( 70 | get_client_id(), DataCollection.AUTHENTICATION, settings 71 | ) as repository: 72 | query_result = repository.get({"username": credential.username}) 73 | if not query_result: 74 | repository.set(credential.dict()) 75 | else: 76 | raise HTTPException( 77 | status_code=status.HTTP_409_CONFLICT, 78 | detail=f"Already exists {credential.username}", 79 | ) 80 | except Exception as error: 81 | print(sys.exc_info()) 82 | raise HTTPException( 83 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 84 | detail="set auth credential failed", 85 | ) from error 86 | 87 | 88 | def update_auth_credential( 89 | credential: AuthenticationCredential, settings: Settings = Settings() 90 | ) -> BaseResponse: 91 | """Update an authentication credential in the repository.""" 92 | try: 93 | with get_repository( 94 | get_client_id(), DataCollection.AUTHENTICATION, settings 95 | ) as repository: 96 | query_result = repository.get({"username": credential.username}) 97 | if not query_result: 98 | raise HTTPException( 99 | status_code=status.HTTP_404_NOT_FOUND, 100 | detail=f"Could not find credential {credential.username}", 101 | ) 102 | repository.update({"username": credential.username}, credential.dict()) 103 | return BaseResponse() 104 | except Exception as error: 105 | print(sys.exc_info()) 106 | raise HTTPException( 107 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 108 | detail="update auth credential failed", 109 | ) from error 110 | -------------------------------------------------------------------------------- /app/core/ballot.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List, Optional 2 | import sys 3 | from fastapi import HTTPException, status 4 | 5 | from electionguard.ballot import ( 6 | SubmittedBallot, 7 | ) 8 | 9 | from .repository import get_repository, DataCollection 10 | from .settings import Settings 11 | from ..api.v1.models import BaseResponse, BallotInventory 12 | 13 | 14 | __all__ = [ 15 | "get_ballot", 16 | "set_ballots", 17 | "filter_ballots", 18 | "get_ballot_inventory", 19 | "upsert_ballot_inventory", 20 | ] 21 | 22 | 23 | def get_ballot( 24 | election_id: str, ballot_id: str, settings: Settings = Settings() 25 | ) -> SubmittedBallot: 26 | try: 27 | with get_repository( 28 | election_id, DataCollection.SUBMITTED_BALLOT, settings 29 | ) as repository: 30 | query_result = repository.get({"object_id": ballot_id}) 31 | if not query_result: 32 | raise HTTPException( 33 | status_code=status.HTTP_404_NOT_FOUND, 34 | detail=f"Could not find ballot_id {ballot_id}", 35 | ) 36 | return SubmittedBallot.from_json_object(query_result) 37 | except Exception as error: 38 | print(sys.exc_info()) 39 | raise HTTPException( 40 | status_code=status.HTTP_404_NOT_FOUND, 41 | detail=f"{ballot_id} not found", 42 | ) from error 43 | 44 | 45 | def set_ballots( 46 | election_id: str, ballots: List[SubmittedBallot], settings: Settings = Settings() 47 | ) -> BaseResponse: 48 | try: 49 | with get_repository( 50 | election_id, DataCollection.SUBMITTED_BALLOT, settings 51 | ) as repository: 52 | cacheable_ballots = [ballot.to_json_object() for ballot in ballots] 53 | _ = repository.set(cacheable_ballots) 54 | return BaseResponse( 55 | message="Ballots Successfully Set", 56 | ) 57 | except Exception as error: 58 | print(sys.exc_info()) 59 | raise HTTPException( 60 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 61 | detail="set ballots failed", 62 | ) from error 63 | 64 | 65 | def filter_ballots( 66 | election_id: str, 67 | filter: Any, 68 | skip: int = 0, 69 | limit: int = 1000, 70 | settings: Settings = Settings(), 71 | ) -> List[SubmittedBallot]: 72 | try: 73 | with get_repository( 74 | election_id, DataCollection.SUBMITTED_BALLOT, settings 75 | ) as repository: 76 | cursor = repository.find(filter, skip, limit) 77 | ballots: List[SubmittedBallot] = [] 78 | for item in cursor: 79 | ballots.append(SubmittedBallot.from_json_object(item)) 80 | return ballots 81 | except Exception as error: 82 | print(sys.exc_info()) 83 | raise HTTPException( 84 | status_code=status.HTTP_404_NOT_FOUND, 85 | detail="provided filter not found", 86 | ) from error 87 | 88 | 89 | def get_ballot_inventory( 90 | election_id: str, settings: Settings = Settings() 91 | ) -> Optional[BallotInventory]: 92 | try: 93 | with get_repository( 94 | election_id, DataCollection.BALLOT_INVENTORY, settings 95 | ) as repository: 96 | query_result = repository.get({"election_id": election_id}) 97 | if not query_result: 98 | return None 99 | return BallotInventory( 100 | election_id=query_result["election_id"], 101 | cast_ballot_count=query_result["cast_ballot_count"], 102 | spoiled_ballot_count=query_result["spoiled_ballot_count"], 103 | cast_ballots=query_result["cast_ballots"], 104 | spoiled_ballots=query_result["spoiled_ballots"], 105 | ) 106 | except Exception as error: 107 | print(sys.exc_info()) 108 | raise HTTPException( 109 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 110 | detail="get ballot inventory failed", 111 | ) from error 112 | 113 | 114 | def upsert_ballot_inventory( 115 | election_id: str, inventory: BallotInventory, settings: Settings = Settings() 116 | ) -> BaseResponse: 117 | try: 118 | with get_repository( 119 | election_id, DataCollection.BALLOT_INVENTORY, settings 120 | ) as repository: 121 | query_result = repository.get({"election_id": election_id}) 122 | if not query_result: 123 | repository.set(inventory.dict()) 124 | else: 125 | repository.update({"election_id": election_id}, inventory.dict()) 126 | return BaseResponse() 127 | except Exception as error: 128 | print(sys.exc_info()) 129 | raise HTTPException( 130 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 131 | detail="update ballot inventory failed", 132 | ) from error 133 | -------------------------------------------------------------------------------- /app/core/client.py: -------------------------------------------------------------------------------- 1 | # TODO: multi-tenancy 2 | DEFAULT_CLIENT_ID = "electionguard-default-client-id" 3 | 4 | __all__ = [ 5 | "get_client_id", 6 | ] 7 | 8 | 9 | def get_client_id() -> str: 10 | return DEFAULT_CLIENT_ID 11 | -------------------------------------------------------------------------------- /app/core/election.py: -------------------------------------------------------------------------------- 1 | import traceback 2 | from typing import Any, List 3 | 4 | from fastapi import HTTPException, status 5 | 6 | from electionguard.serializable import write_json_object 7 | 8 | from .client import get_client_id 9 | from .repository import get_repository, DataCollection 10 | from .settings import Settings 11 | from ..api.v1.models import BaseResponse, Election, ElectionState 12 | 13 | __all__ = [ 14 | "election_from_query", 15 | "get_election", 16 | "set_election", 17 | "filter_elections", 18 | "update_election_state", 19 | ] 20 | 21 | 22 | def election_from_query(query_result: Any) -> Election: 23 | return Election( 24 | election_id=query_result["election_id"], 25 | key_name=query_result["key_name"], 26 | state=query_result["state"], 27 | context=query_result["context"], 28 | manifest=query_result["manifest"], 29 | ) 30 | 31 | 32 | def get_election(election_id: str, settings: Settings = Settings()) -> Election: 33 | try: 34 | with get_repository( 35 | get_client_id(), DataCollection.ELECTION, settings 36 | ) as repository: 37 | query_result = repository.get({"election_id": election_id}) 38 | if not query_result: 39 | raise HTTPException( 40 | status_code=status.HTTP_404_NOT_FOUND, 41 | detail=f"Could not find election {election_id}", 42 | ) 43 | election = election_from_query(query_result) 44 | 45 | return election 46 | except Exception as error: 47 | traceback.print_exc() 48 | raise HTTPException( 49 | status_code=status.HTTP_404_NOT_FOUND, 50 | detail=f"{election_id} not found", 51 | ) from error 52 | 53 | 54 | def set_election(election: Election, settings: Settings = Settings()) -> BaseResponse: 55 | try: 56 | with get_repository( 57 | get_client_id(), DataCollection.ELECTION, settings 58 | ) as repository: 59 | _ = repository.set(write_json_object(election.dict())) 60 | return BaseResponse( 61 | message="Election Successfully Set", 62 | ) 63 | except Exception as error: 64 | traceback.print_exc() 65 | raise HTTPException( 66 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 67 | detail="Submit election failed", 68 | ) from error 69 | 70 | 71 | def filter_elections( 72 | filter: Any, skip: int = 0, limit: int = 1000, settings: Settings = Settings() 73 | ) -> List[Election]: 74 | try: 75 | with get_repository( 76 | get_client_id(), DataCollection.ELECTION, settings 77 | ) as repository: 78 | cursor = repository.find(filter, skip, limit) 79 | elections: List[Election] = [] 80 | for item in cursor: 81 | elections.append(election_from_query(item)) 82 | return elections 83 | except Exception as error: 84 | traceback.print_exc() 85 | raise HTTPException( 86 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 87 | detail="find elections failed", 88 | ) from error 89 | 90 | 91 | def update_election_state( 92 | election_id: str, new_state: ElectionState, settings: Settings = Settings() 93 | ) -> BaseResponse: 94 | try: 95 | with get_repository( 96 | get_client_id(), DataCollection.ELECTION, settings 97 | ) as repository: 98 | query_result = repository.get({"election_id": election_id}) 99 | if not query_result: 100 | raise HTTPException( 101 | status_code=status.HTTP_404_NOT_FOUND, 102 | detail=f"Could not find election {election_id}", 103 | ) 104 | election = election_from_query(query_result) 105 | election.state = new_state 106 | repository.update({"election_id": election_id}, election.dict()) 107 | return BaseResponse() 108 | except Exception as error: 109 | traceback.print_exc() 110 | raise HTTPException( 111 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 112 | detail="update election failed", 113 | ) from error 114 | -------------------------------------------------------------------------------- /app/core/guardian.py: -------------------------------------------------------------------------------- 1 | import traceback 2 | from typing import Any 3 | import sys 4 | from fastapi import HTTPException, status 5 | 6 | from electionguard.serializable import write_json_object 7 | 8 | from .client import get_client_id 9 | from .repository import get_repository, DataCollection 10 | from .settings import Settings 11 | from ..api.v1.models import ( 12 | BaseResponse, 13 | Guardian, 14 | ) 15 | 16 | __all__ = [ 17 | "guardian_from_query", 18 | "get_guardian", 19 | "update_guardian", 20 | ] 21 | 22 | 23 | def guardian_from_query(query_result: Any) -> Guardian: 24 | return Guardian( 25 | guardian_id=query_result["guardian_id"], 26 | name=query_result["name"], 27 | sequence_order=query_result["sequence_order"], 28 | number_of_guardians=query_result["number_of_guardians"], 29 | quorum=query_result["quorum"], 30 | election_keys=write_json_object(query_result["election_keys"]), 31 | auxiliary_keys=write_json_object(query_result["auxiliary_keys"]), 32 | backups=query_result["backups"], 33 | cohort_public_keys=query_result["cohort_public_keys"], 34 | cohort_backups=query_result["cohort_backups"], 35 | cohort_verifications=query_result["cohort_verifications"], 36 | cohort_challenges=query_result["cohort_challenges"], 37 | ) 38 | 39 | 40 | def get_guardian(guardian_id: str, settings: Settings = Settings()) -> Guardian: 41 | try: 42 | with get_repository( 43 | get_client_id(), DataCollection.GUARDIAN, settings 44 | ) as repository: 45 | query_result = repository.get({"guardian_id": guardian_id}) 46 | if not query_result: 47 | raise HTTPException( 48 | status_code=status.HTTP_404_NOT_FOUND, 49 | detail=f"Could not find guardian {guardian_id}", 50 | ) 51 | guardian = guardian_from_query(query_result) 52 | return guardian 53 | except Exception as error: 54 | traceback.print_exc() 55 | print(sys.exc_info()) 56 | raise HTTPException( 57 | status_code=status.HTTP_404_NOT_FOUND, 58 | detail=f"{guardian_id} not found", 59 | ) from error 60 | 61 | 62 | def update_guardian( 63 | guardian_id: str, guardian: Guardian, settings: Settings = Settings() 64 | ) -> BaseResponse: 65 | try: 66 | with get_repository( 67 | get_client_id(), DataCollection.GUARDIAN, settings 68 | ) as repository: 69 | query_result = repository.get({"guardian_id": guardian_id}) 70 | if not query_result: 71 | raise HTTPException( 72 | status_code=status.HTTP_404_NOT_FOUND, 73 | detail=f"Could not find guardian {guardian_id}", 74 | ) 75 | repository.update({"guardian_id": guardian_id}, guardian.dict()) 76 | return BaseResponse() 77 | except Exception as error: 78 | print(sys.exc_info()) 79 | raise HTTPException( 80 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 81 | detail="update guardian failed", 82 | ) from error 83 | -------------------------------------------------------------------------------- /app/core/key_ceremony.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | import sys 3 | from fastapi import HTTPException, status 4 | 5 | from .client import get_client_id 6 | from .repository import get_repository, DataCollection 7 | from .settings import Settings 8 | from ..api.v1.models import ( 9 | BaseResponse, 10 | KeyCeremony, 11 | KeyCeremonyState, 12 | KeyCeremonyGuardianStatus, 13 | ) 14 | 15 | 16 | __all__ = [ 17 | "key_ceremony_from_query", 18 | "get_key_ceremony", 19 | "update_key_ceremony", 20 | "update_key_ceremony_state", 21 | "validate_can_publish", 22 | ] 23 | 24 | 25 | def key_ceremony_from_query(query_result: Any) -> KeyCeremony: 26 | return KeyCeremony( 27 | key_name=query_result["key_name"], 28 | state=query_result["state"], 29 | number_of_guardians=query_result["number_of_guardians"], 30 | quorum=query_result["quorum"], 31 | guardian_ids=query_result["guardian_ids"], 32 | guardian_status=query_result["guardian_status"], 33 | elgamal_public_key=query_result["elgamal_public_key"], 34 | commitment_hash=query_result["commitment_hash"], 35 | ) 36 | 37 | 38 | def get_key_ceremony(key_name: str, settings: Settings = Settings()) -> KeyCeremony: 39 | try: 40 | with get_repository( 41 | get_client_id(), DataCollection.KEY_CEREMONY, settings 42 | ) as repository: 43 | query_result = repository.get({"key_name": key_name}) 44 | if not query_result: 45 | raise HTTPException( 46 | status_code=status.HTTP_404_NOT_FOUND, 47 | detail=f"Could not find key ceremony {key_name}", 48 | ) 49 | key_ceremony = key_ceremony_from_query(query_result) 50 | return key_ceremony 51 | except Exception as error: 52 | print(sys.exc_info()) 53 | raise HTTPException( 54 | status_code=status.HTTP_404_NOT_FOUND, 55 | detail=f"{key_name} not found", 56 | ) from error 57 | 58 | 59 | def update_key_ceremony( 60 | key_name: str, ceremony: KeyCeremony, settings: Settings = Settings() 61 | ) -> BaseResponse: 62 | try: 63 | with get_repository( 64 | get_client_id(), DataCollection.KEY_CEREMONY, settings 65 | ) as repository: 66 | query_result = repository.get({"key_name": key_name}) 67 | if not query_result: 68 | raise HTTPException( 69 | status_code=status.HTTP_404_NOT_FOUND, 70 | detail=f"Could not find key ceremony {key_name}", 71 | ) 72 | repository.update({"key_name": key_name}, ceremony.dict()) 73 | return BaseResponse() 74 | except Exception as error: 75 | print(sys.exc_info()) 76 | raise HTTPException( 77 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 78 | detail="update key ceremony failed", 79 | ) from error 80 | 81 | 82 | def update_key_ceremony_state( 83 | key_name: str, new_state: KeyCeremonyState, settings: Settings = Settings() 84 | ) -> BaseResponse: 85 | try: 86 | with get_repository( 87 | get_client_id(), DataCollection.KEY_CEREMONY, settings 88 | ) as repository: 89 | query_result = repository.get({"key_name": key_name}) 90 | if not query_result: 91 | raise HTTPException( 92 | status_code=status.HTTP_404_NOT_FOUND, 93 | detail=f"Could not find key ceremony {key_name}", 94 | ) 95 | key_ceremony = key_ceremony_from_query(query_result) 96 | key_ceremony.state = new_state 97 | 98 | repository.update({"key_name": key_name}, key_ceremony.dict()) 99 | return BaseResponse() 100 | except Exception as error: 101 | print(sys.exc_info()) 102 | raise HTTPException( 103 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 104 | detail="update key ceremony state failed", 105 | ) from error 106 | 107 | 108 | def validate_can_publish(ceremony: KeyCeremony) -> None: 109 | # TODO: better validation 110 | for guardian_id, state in ceremony.guardian_status.items(): 111 | if ( 112 | state.public_key_shared != KeyCeremonyGuardianStatus.COMPLETE 113 | or state.backups_shared != KeyCeremonyGuardianStatus.COMPLETE 114 | or state.backups_verified != KeyCeremonyGuardianStatus.COMPLETE 115 | ): 116 | raise HTTPException( 117 | status_code=status.HTTP_412_PRECONDITION_FAILED, 118 | detail=f"Publish constraint not satisfied for {guardian_id}", 119 | ) 120 | -------------------------------------------------------------------------------- /app/core/key_guardian.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from fastapi import HTTPException, status 3 | 4 | from .client import get_client_id 5 | from .repository import get_repository, DataCollection 6 | from .settings import Settings 7 | from ..api.v1.models import ( 8 | BaseResponse, 9 | KeyCeremonyGuardian, 10 | ) 11 | 12 | __all__ = [ 13 | "get_key_guardian", 14 | "update_key_guardian", 15 | ] 16 | 17 | 18 | def get_key_guardian( 19 | key_name: str, guardian_id: str, settings: Settings = Settings() 20 | ) -> KeyCeremonyGuardian: 21 | try: 22 | with get_repository( 23 | get_client_id(), DataCollection.KEY_GUARDIAN, settings 24 | ) as repository: 25 | query_result = repository.get( 26 | {"key_name": key_name, "guardian_id": guardian_id} 27 | ) 28 | if not query_result: 29 | raise HTTPException( 30 | status_code=status.HTTP_404_NOT_FOUND, 31 | detail=f"Could not find guardian {guardian_id}", 32 | ) 33 | guardian = KeyCeremonyGuardian( 34 | key_name=query_result["key_name"], 35 | guardian_id=query_result["guardian_id"], 36 | name=query_result["name"], 37 | sequence_order=query_result["sequence_order"], 38 | number_of_guardians=query_result["number_of_guardians"], 39 | quorum=query_result["quorum"], 40 | public_keys=query_result["public_keys"], 41 | backups=query_result["backups"], 42 | verifications=query_result["verifications"], 43 | challenges=query_result["challenges"], 44 | ) 45 | return guardian 46 | except Exception as error: 47 | print(sys.exc_info()) 48 | raise HTTPException( 49 | status_code=status.HTTP_404_NOT_FOUND, 50 | detail=f"{key_name} {guardian_id} not found", 51 | ) from error 52 | 53 | 54 | def update_key_guardian( 55 | key_name: str, 56 | guardian_id: str, 57 | guardian: KeyCeremonyGuardian, 58 | settings: Settings = Settings(), 59 | ) -> BaseResponse: 60 | try: 61 | with get_repository( 62 | get_client_id(), DataCollection.KEY_GUARDIAN, settings 63 | ) as repository: 64 | query_result = repository.get( 65 | {"key_name": key_name, "guardian_id": guardian_id} 66 | ) 67 | if not query_result: 68 | raise HTTPException( 69 | status_code=status.HTTP_404_NOT_FOUND, 70 | detail=f"Could not find guardian {guardian_id}", 71 | ) 72 | repository.update({"guardian_id": guardian_id}, guardian.dict()) 73 | return BaseResponse() 74 | except Exception as error: 75 | print(sys.exc_info()) 76 | raise HTTPException( 77 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 78 | detail="update key ceremony guardian failed", 79 | ) from error 80 | -------------------------------------------------------------------------------- /app/core/manifest.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List 2 | import sys 3 | 4 | from fastapi import HTTPException, status 5 | 6 | from electionguard.group import ElementModQ 7 | from electionguard.serializable import read_json_object, write_json_object 8 | 9 | import electionguard.manifest 10 | 11 | from .client import get_client_id 12 | from .repository import get_repository, DataCollection 13 | from .settings import Settings 14 | from ..api.v1.models import Manifest, ManifestSubmitResponse, ManifestQueryResponse 15 | 16 | __all__ = [ 17 | "from_manifest_query", 18 | "get_manifest", 19 | "set_manifest", 20 | "filter_manifests", 21 | ] 22 | 23 | # TODO: rework the caching mechanism to reduce the amount of object conversions 24 | def from_manifest_query(query_result: Any) -> Manifest: 25 | sdk_manifest = electionguard.manifest.Manifest.from_json_object( 26 | query_result["manifest"] 27 | ) 28 | return Manifest( 29 | manifest_hash=write_json_object(sdk_manifest.crypto_hash()), 30 | manifest=write_json_object(sdk_manifest), 31 | ) 32 | 33 | 34 | def get_manifest( 35 | manifest_hash: ElementModQ, settings: Settings = Settings() 36 | ) -> Manifest: 37 | try: 38 | with get_repository( 39 | get_client_id(), DataCollection.MANIFEST, settings 40 | ) as repository: 41 | query_result = repository.get({"manifest_hash": manifest_hash.to_hex()}) 42 | if not query_result: 43 | raise HTTPException( 44 | status_code=status.HTTP_404_NOT_FOUND, 45 | detail=f"Could not find manifest {manifest_hash.to_hex()}", 46 | ) 47 | 48 | return from_manifest_query(query_result) 49 | except Exception as error: 50 | print(sys.exc_info()) 51 | raise HTTPException( 52 | status_code=status.HTTP_404_NOT_FOUND, 53 | detail=f"{manifest_hash} not found", 54 | ) from error 55 | 56 | 57 | def set_manifest( 58 | manifest: Manifest, settings: Settings = Settings() 59 | ) -> ManifestSubmitResponse: 60 | try: 61 | with get_repository( 62 | get_client_id(), DataCollection.MANIFEST, settings 63 | ) as repository: 64 | manifest_hash = read_json_object( 65 | manifest.manifest_hash, ElementModQ 66 | ).to_hex() 67 | _ = repository.set( 68 | {"manifest_hash": manifest_hash, "manifest": manifest.manifest} 69 | ) 70 | return ManifestSubmitResponse(manifest_hash=manifest_hash) 71 | except Exception as error: 72 | print(sys.exc_info()) 73 | raise HTTPException( 74 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 75 | detail="Submit manifest failed", 76 | ) from error 77 | 78 | 79 | def filter_manifests( 80 | filter: Any, skip: int = 0, limit: int = 1000, settings: Settings = Settings() 81 | ) -> ManifestQueryResponse: 82 | try: 83 | with get_repository( 84 | get_client_id(), DataCollection.MANIFEST, settings 85 | ) as repository: 86 | cursor = repository.find(filter, skip, limit) 87 | manifests: List[Manifest] = [] 88 | for item in cursor: 89 | manifests.append(from_manifest_query(item)) 90 | return ManifestQueryResponse(manifests=manifests) 91 | except Exception as error: 92 | print(sys.exc_info()) 93 | raise HTTPException( 94 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 95 | detail="find manifests failed", 96 | ) from error 97 | -------------------------------------------------------------------------------- /app/core/queue.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable, Any, Protocol 2 | from queue import SimpleQueue 3 | 4 | import sys 5 | import pika 6 | 7 | from .settings import Settings, QueueMode 8 | 9 | __all__ = [ 10 | "IMessageQueue", 11 | "MemoryMessageQueue", 12 | "RabbitMQMessageQueue", 13 | "get_message_queue", 14 | ] 15 | 16 | 17 | class IMessageQueue(Protocol): 18 | def __enter__(self) -> Any: 19 | return self 20 | 21 | def __exit__(self, exc_type: Any, exc_value: Any, exc_traceback: Any) -> None: 22 | pass 23 | 24 | def publish(self, body: str) -> Any: 25 | """ 26 | Get an item from the container 27 | """ 28 | 29 | def subscribe(self) -> Iterable[Any]: 30 | """ 31 | Set and item in the container 32 | """ 33 | 34 | 35 | class MemoryMessageQueue(IMessageQueue): 36 | def __init__(self, queue: str, topic: str): 37 | super().__init__() 38 | self._storage: SimpleQueue = SimpleQueue() 39 | self._queue = queue 40 | self._topic = topic 41 | 42 | def __enter__(self) -> Any: 43 | return self 44 | 45 | def __exit__(self, exc_type: Any, exc_value: Any, exc_traceback: Any) -> None: 46 | pass 47 | 48 | def publish(self, body: str) -> None: 49 | print("MemoryMessageQueue: publish") 50 | self._storage.put_nowait(body) 51 | 52 | def subscribe(self) -> Iterable[Any]: 53 | while self._storage.qsize() > 0: 54 | item = self._storage.get_nowait() 55 | print("MemoryMessageQueue: subscribe") 56 | yield item 57 | 58 | 59 | class RabbitMQMessageQueue(IMessageQueue): 60 | def __init__(self, uri: str, queue: str, topic: str): 61 | super().__init__() 62 | self._queue = queue 63 | self._topic = topic 64 | self._params = pika.URLParameters(uri) 65 | self._client: pika.BlockingConnection = None 66 | 67 | def __enter__(self) -> Any: 68 | self._client = pika.BlockingConnection(self._params) 69 | return self 70 | 71 | def __exit__(self, exc_type: Any, exc_value: Any, exc_traceback: Any) -> None: 72 | self._client.close() 73 | 74 | def publish(self, body: str) -> None: 75 | try: 76 | channel = self._client.channel() 77 | channel.queue_declare(queue=self._queue) 78 | channel.basic_publish( 79 | exchange="", 80 | routing_key=self._topic, 81 | body=body, 82 | ) 83 | channel.close() 84 | except (pika.exceptions.ChannelError, pika.exceptions.StreamLostError): 85 | print(sys.exc_info()) 86 | 87 | def subscribe(self) -> Iterable[Any]: 88 | channel = self._client.channel() 89 | data = channel.basic_get(self._topic, True) 90 | while data[0]: 91 | yield data[2] 92 | data = channel.basic_get(self._topic, True) 93 | channel.close() 94 | 95 | 96 | def get_message_queue( 97 | queue: str, topic: str, settings: Settings = Settings() 98 | ) -> IMessageQueue: 99 | if settings.QUEUE_MODE == QueueMode.REMOTE: 100 | return RabbitMQMessageQueue(settings.MESSAGEQUEUE_URI, queue, topic) 101 | return MemoryMessageQueue(queue, topic) 102 | -------------------------------------------------------------------------------- /app/core/repository.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List, Union 2 | from collections.abc import MutableMapping 3 | from abc import ABC, abstractmethod 4 | 5 | import mmap 6 | import os 7 | import json 8 | import re 9 | 10 | from pymongo import MongoClient 11 | from pymongo.database import Database 12 | 13 | from electionguard.hash import hash_elems 14 | 15 | from .settings import Settings, StorageMode 16 | 17 | __all__ = [ 18 | "IRepository", 19 | "LocalRepository", 20 | "MongoRepository", 21 | "get_repository", 22 | ] 23 | 24 | 25 | DOCUMENT_VALUE_TYPE = Union[MutableMapping, List[MutableMapping]] 26 | 27 | 28 | class IRepository(ABC): 29 | def __enter__(self) -> Any: 30 | return self 31 | 32 | def __exit__(self, exc_type: Any, exc_value: Any, exc_traceback: Any) -> None: 33 | pass 34 | 35 | @abstractmethod 36 | def find(self, filter: MutableMapping, skip: int = 0, limit: int = 0) -> Any: 37 | """ 38 | Find items matching the filter 39 | """ 40 | 41 | @abstractmethod 42 | def get(self, filter: MutableMapping) -> Any: 43 | """ 44 | Get an item from the container 45 | """ 46 | 47 | @abstractmethod 48 | def set(self, value: DOCUMENT_VALUE_TYPE) -> Any: 49 | """ 50 | Set and item in the container 51 | """ 52 | 53 | @abstractmethod 54 | def update(self, filter: MutableMapping, value: DOCUMENT_VALUE_TYPE) -> Any: 55 | """ 56 | Update an item 57 | """ 58 | 59 | 60 | class DataCollection: 61 | AUTHENTICATION = "authenticationContext" 62 | GUARDIAN = "guardian" 63 | KEY_GUARDIAN = "keyGuardian" 64 | KEY_CEREMONY = "keyCeremony" 65 | ELECTION = "election" 66 | MANIFEST = "manifest" 67 | BALLOT_INVENTORY = "ballotInventory" 68 | SUBMITTED_BALLOT = "submittedBallots" 69 | CIPHERTEXT_TALLY = "ciphertextTally" 70 | PLAINTEXT_TALLY = "plaintextTally" 71 | DECRYPTION_SHARES = "decryptionShares" 72 | USER_INFO = "userInfo" 73 | 74 | 75 | class LocalRepository(IRepository): 76 | """A simple local storage interface. For testing only.""" 77 | 78 | def __init__( 79 | self, 80 | container: str, 81 | collection: str, 82 | ): 83 | super().__init__() 84 | self._id = 0 85 | self._container = container 86 | self._collection = collection 87 | self._storage = os.path.join( 88 | os.getcwd(), "storage", self._container, self._collection 89 | ) 90 | 91 | def __enter__(self) -> Any: 92 | if not os.path.exists(self._storage): 93 | os.makedirs(self._storage) 94 | return self 95 | 96 | def __exit__(self, exc_type: Any, exc_value: Any, exc_traceback: Any) -> None: 97 | pass 98 | 99 | def find(self, filter: MutableMapping, skip: int = 0, limit: int = 0) -> Any: 100 | # TODO: implement a find function 101 | pass 102 | 103 | def get(self, filter: MutableMapping) -> Any: 104 | """An inefficient search through all files in the directory.""" 105 | # query_string = json.dumps(dict(filter)) 106 | query_string = re.sub(r"\{|\}", r"", json.dumps(dict(filter))) 107 | 108 | search_files = [ 109 | file 110 | for file in os.listdir(self._storage) 111 | if os.path.isfile(os.path.join(self._storage, file)) 112 | ] 113 | 114 | for filename in search_files: 115 | try: 116 | with open( 117 | os.path.join(self._storage, filename), encoding="utf-8" 118 | ) as file, mmap.mmap( 119 | file.fileno(), 0, access=mmap.ACCESS_READ 120 | ) as search: 121 | if search.find(bytes(query_string, "utf-8")) != -1: 122 | json_string = file.read() 123 | return json.loads(json_string) 124 | except FileNotFoundError: 125 | # swallow errors 126 | pass 127 | return None 128 | 129 | def set(self, value: DOCUMENT_VALUE_TYPE) -> Any: 130 | """A naive set function that hashes the data and writes the file.""" 131 | # just ignore lists for now 132 | if isinstance(value, List): 133 | raise Exception("Not Implemented") 134 | json_string = json.dumps(dict(value)) 135 | filename = hash_elems(json_string).to_hex() 136 | with open( 137 | f"{os.path.join(self._storage, filename)}.json", "w", encoding="utf-8" 138 | ) as file: 139 | file.write(json_string) 140 | return filename 141 | 142 | def update(self, filter: MutableMapping, value: DOCUMENT_VALUE_TYPE) -> Any: 143 | # TODO: implement an update function 144 | pass 145 | 146 | 147 | class MongoRepository(IRepository): 148 | def __init__( 149 | self, 150 | uri: str, 151 | container: str, 152 | collection: str, 153 | ): 154 | super().__init__() 155 | self._uri = uri 156 | self._container = container 157 | self._collection = collection 158 | self._client: MongoClient = None 159 | self._database: Database = None 160 | 161 | def __enter__(self) -> Any: 162 | self._client = MongoClient(self._uri) 163 | self._database = self._client.get_database(self._container) 164 | return self 165 | 166 | def __exit__(self, exc_type: Any, exc_value: Any, exc_traceback: Any) -> None: 167 | self._client.close() 168 | 169 | def find(self, filter: MutableMapping, skip: int = 0, limit: int = 0) -> Any: 170 | collection = self._database.get_collection(self._collection) 171 | return collection.find(filter=filter, skip=skip, limit=limit) 172 | 173 | def get(self, filter: MutableMapping) -> Any: 174 | collection = self._database.get_collection(self._collection) 175 | return collection.find_one(filter) 176 | 177 | def set(self, value: DOCUMENT_VALUE_TYPE) -> Any: 178 | collection = self._database.get_collection(self._collection) 179 | if isinstance(value, List): 180 | result = collection.insert_many(value) 181 | return [str(id) for id in result.inserted_ids] 182 | result = collection.insert_one(value) 183 | return [str(result.inserted_id)] 184 | 185 | def update(self, filter: MutableMapping, value: DOCUMENT_VALUE_TYPE) -> Any: 186 | collection = self._database.get_collection(self._collection) 187 | return collection.update_one(filter=filter, update={"$set": value}) 188 | 189 | 190 | def get_repository( 191 | container: str, collection: str, settings: Settings = Settings() 192 | ) -> IRepository: 193 | """Get a repository by settings strage mode.""" 194 | if settings.STORAGE_MODE == StorageMode.MONGO: 195 | return MongoRepository(settings.MONGODB_URI, container, collection) 196 | 197 | if settings.STORAGE_MODE == StorageMode.LOCAL_STORAGE: 198 | return LocalRepository(container, collection) 199 | 200 | raise ValueError("Unsupported storage mode: " + settings.STORAGE_MODE) 201 | -------------------------------------------------------------------------------- /app/core/scheduler.py: -------------------------------------------------------------------------------- 1 | from functools import lru_cache 2 | from electionguard.scheduler import Scheduler 3 | 4 | __all__ = ["get_scheduler"] 5 | 6 | 7 | @lru_cache 8 | def get_scheduler() -> Scheduler: 9 | return Scheduler() 10 | -------------------------------------------------------------------------------- /app/core/schema.py: -------------------------------------------------------------------------------- 1 | from functools import lru_cache 2 | from typing import Any 3 | from electionguard.schema import get_election_description_schema 4 | 5 | __all__ = ["get_description_schema"] 6 | 7 | 8 | @lru_cache 9 | def get_description_schema() -> Any: 10 | return get_election_description_schema() 11 | -------------------------------------------------------------------------------- /app/core/settings.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from enum import Enum 3 | from pydantic import AnyHttpUrl, BaseSettings 4 | from pydantic.fields import Field 5 | 6 | __all__ = [ 7 | "ApiMode", 8 | "QueueMode", 9 | "StorageMode", 10 | "Settings", 11 | ] 12 | 13 | 14 | class ApiMode(str, Enum): 15 | GUARDIAN = "guardian" 16 | MEDIATOR = "mediator" 17 | 18 | 19 | class QueueMode(str, Enum): 20 | LOCAL = "local" 21 | REMOTE = "remote" 22 | 23 | 24 | class StorageMode(str, Enum): 25 | LOCAL_STORAGE = "local_storage" 26 | MONGO = "mongo" 27 | 28 | 29 | # pylint:disable=too-few-public-methods 30 | class Settings(BaseSettings): 31 | API_MODE: ApiMode = ApiMode.MEDIATOR 32 | QUEUE_MODE: QueueMode = QueueMode.LOCAL 33 | STORAGE_MODE: StorageMode = StorageMode.LOCAL_STORAGE 34 | 35 | API_V1_STR: str = "/api/v1" 36 | API_V1_1_STR: str = "/api/v1_1" 37 | BACKEND_CORS_ORIGINS: List[AnyHttpUrl] = Field( 38 | default=[ 39 | "http://localhost", 40 | "http://localhost:8080", 41 | "http://localhost:3001", 42 | "https://localhost", 43 | ] 44 | ) 45 | PROJECT_NAME: str = "electionguard-api-python" 46 | MONGODB_URI: str = "mongodb://root:example@localhost:27017" 47 | MESSAGEQUEUE_URI: str = "amqp://guest:guest@localhost:5672" 48 | 49 | AUTH_ALGORITHM = "HS256" 50 | # JWT secret that matches AUTH_ALGORITHM. Change this to a valid value. 51 | AUTH_SECRET_KEY = "<this is a default value and should be changed>" 52 | AUTH_ACCESS_TOKEN_EXPIRE_MINUTES = 30 53 | DEFAULT_ADMIN_USERNAME = "default" 54 | DEFAULT_ADMIN_PASSWORD = "<this is a default value and should be changed>" 55 | # this is a default value that will be moving to the environment settings 56 | # the default value should not be used for production use 57 | 58 | class Config: 59 | case_sensitive = True 60 | -------------------------------------------------------------------------------- /app/core/tally.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List 2 | import sys 3 | from fastapi import HTTPException, status 4 | 5 | from .repository import get_repository, DataCollection 6 | from .settings import Settings 7 | from ..api.v1.models import BaseResponse, CiphertextTally, PlaintextTally 8 | 9 | __all__ = [ 10 | "ciphertext_tally_from_query", 11 | "plaintext_tally_from_query", 12 | "get_ciphertext_tally", 13 | "set_ciphertext_tally", 14 | "filter_ciphertext_tallies", 15 | "get_plaintext_tally", 16 | "set_plaintext_tally", 17 | "update_plaintext_tally", 18 | "filter_plaintext_tallies", 19 | ] 20 | 21 | 22 | def ciphertext_tally_from_query(query_result: Any) -> CiphertextTally: 23 | return CiphertextTally( 24 | election_id=query_result["election_id"], 25 | tally_name=query_result["tally_name"], 26 | created=query_result["created"], 27 | tally=query_result["tally"], 28 | ) 29 | 30 | 31 | def plaintext_tally_from_query(query_result: Any) -> PlaintextTally: 32 | return PlaintextTally( 33 | election_id=query_result["election_id"], 34 | tally_name=query_result["tally_name"], 35 | created=query_result["created"], 36 | state=query_result["state"], 37 | tally=query_result["tally"], 38 | ) 39 | 40 | 41 | def get_ciphertext_tally( 42 | election_id: str, tally_name: str, settings: Settings = Settings() 43 | ) -> CiphertextTally: 44 | try: 45 | with get_repository( 46 | election_id, DataCollection.CIPHERTEXT_TALLY, settings 47 | ) as repository: 48 | query_result = repository.get( 49 | {"election_id": election_id, "tally_name": tally_name} 50 | ) 51 | if not query_result: 52 | raise HTTPException( 53 | status_code=status.HTTP_404_NOT_FOUND, 54 | detail=f"Could not find tally {election_id} {tally_name}", 55 | ) 56 | return ciphertext_tally_from_query(query_result) 57 | except Exception as error: 58 | print(sys.exc_info()) 59 | raise HTTPException( 60 | status_code=status.HTTP_404_NOT_FOUND, 61 | detail=f"{election_id} {tally_name} not found", 62 | ) from error 63 | 64 | 65 | def set_ciphertext_tally( 66 | tally: CiphertextTally, settings: Settings = Settings() 67 | ) -> BaseResponse: 68 | try: 69 | with get_repository( 70 | tally.election_id, DataCollection.CIPHERTEXT_TALLY, settings 71 | ) as repository: 72 | repository.set(tally.dict()) 73 | return BaseResponse() 74 | except Exception as error: 75 | print(sys.exc_info()) 76 | raise HTTPException( 77 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 78 | detail="set ciphertext tally failed", 79 | ) from error 80 | 81 | 82 | def filter_ciphertext_tallies( 83 | election_id: str, 84 | filter: Any, 85 | skip: int = 0, 86 | limit: int = 1000, 87 | settings: Settings = Settings(), 88 | ) -> List[CiphertextTally]: 89 | try: 90 | with get_repository( 91 | election_id, DataCollection.CIPHERTEXT_TALLY, settings 92 | ) as repository: 93 | cursor = repository.find(filter, skip, limit) 94 | tallies: List[CiphertextTally] = [] 95 | for item in cursor: 96 | tallies.append(ciphertext_tally_from_query(item)) 97 | return tallies 98 | except Exception as error: 99 | print(sys.exc_info()) 100 | raise HTTPException( 101 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 102 | detail="filter ciphertext tallies failed", 103 | ) from error 104 | 105 | 106 | def get_plaintext_tally( 107 | election_id: str, tally_name: str, settings: Settings = Settings() 108 | ) -> PlaintextTally: 109 | try: 110 | with get_repository( 111 | election_id, DataCollection.PLAINTEXT_TALLY, settings 112 | ) as repository: 113 | query_result = repository.get( 114 | {"election_id": election_id, "tally_name": tally_name} 115 | ) 116 | if not query_result: 117 | raise HTTPException( 118 | status_code=status.HTTP_404_NOT_FOUND, 119 | detail=f"Could not find tally {election_id} {tally_name}", 120 | ) 121 | return plaintext_tally_from_query(query_result) 122 | except Exception as error: 123 | print(sys.exc_info()) 124 | raise HTTPException( 125 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 126 | detail="get plaintext tally failed", 127 | ) from error 128 | 129 | 130 | def set_plaintext_tally( 131 | tally: PlaintextTally, settings: Settings = Settings() 132 | ) -> BaseResponse: 133 | try: 134 | with get_repository( 135 | tally.election_id, DataCollection.PLAINTEXT_TALLY, settings 136 | ) as repository: 137 | repository.set(tally.dict()) 138 | return BaseResponse() 139 | except Exception as error: 140 | print(sys.exc_info()) 141 | raise HTTPException( 142 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 143 | detail="set plaintext tally failed", 144 | ) from error 145 | 146 | 147 | def update_plaintext_tally( 148 | tally: PlaintextTally, settings: Settings = Settings() 149 | ) -> BaseResponse: 150 | try: 151 | with get_repository( 152 | tally.election_id, DataCollection.PLAINTEXT_TALLY, settings 153 | ) as repository: 154 | query_result = repository.get( 155 | {"election_id": tally.election_id, "tally_name": tally.tally_name} 156 | ) 157 | if not query_result: 158 | raise HTTPException( 159 | status_code=status.HTTP_404_NOT_FOUND, 160 | detail=f"Could not find plaintext tally {tally.election_id} {tally.tally_name}", 161 | ) 162 | repository.update( 163 | {"election_id": tally.election_id, "tally_name": tally.tally_name}, 164 | tally.dict(), 165 | ) 166 | return BaseResponse() 167 | except Exception as error: 168 | print(sys.exc_info()) 169 | raise HTTPException( 170 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 171 | detail="update plaintext tally failed", 172 | ) from error 173 | 174 | 175 | def filter_plaintext_tallies( 176 | election_id: str, 177 | filter: Any, 178 | skip: int = 0, 179 | limit: int = 1000, 180 | settings: Settings = Settings(), 181 | ) -> List[PlaintextTally]: 182 | try: 183 | with get_repository( 184 | election_id, DataCollection.PLAINTEXT_TALLY, settings 185 | ) as repository: 186 | cursor = repository.find(filter, skip, limit) 187 | tallies: List[PlaintextTally] = [] 188 | for item in cursor: 189 | tallies.append(plaintext_tally_from_query(item)) 190 | return tallies 191 | except Exception as error: 192 | print(sys.exc_info()) 193 | raise HTTPException( 194 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 195 | detail="filter plaintext tallies failed", 196 | ) from error 197 | -------------------------------------------------------------------------------- /app/core/tally_decrypt.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List 2 | import sys 3 | from fastapi import HTTPException, status 4 | 5 | from .repository import get_repository, DataCollection 6 | from .settings import Settings 7 | from ..api.v1.models import BaseResponse, CiphertextTallyDecryptionShare 8 | 9 | __all__ = [ 10 | "from_tally_decryption_share_query", 11 | "get_decryption_share", 12 | "set_decryption_share", 13 | "filter_decryption_shares", 14 | ] 15 | 16 | 17 | def from_tally_decryption_share_query( 18 | query_result: Any, 19 | ) -> CiphertextTallyDecryptionShare: 20 | return CiphertextTallyDecryptionShare( 21 | election_id=query_result["election_id"], 22 | tally_name=query_result["tally_name"], 23 | guardian_id=query_result["guardian_id"], 24 | tally_share=query_result["tally_share"], 25 | ballot_shares=query_result["ballot_shares"], 26 | ) 27 | 28 | 29 | def get_decryption_share( 30 | election_id: str, tally_name: str, guardian_id: str, settings: Settings = Settings() 31 | ) -> CiphertextTallyDecryptionShare: 32 | try: 33 | with get_repository( 34 | tally_name, DataCollection.DECRYPTION_SHARES, settings 35 | ) as repository: 36 | query_result = repository.get( 37 | { 38 | "election_id": election_id, 39 | "tally_name": tally_name, 40 | "guardian_id": guardian_id, 41 | } 42 | ) 43 | if not query_result: 44 | raise HTTPException( 45 | status_code=status.HTTP_404_NOT_FOUND, 46 | detail=f"Could not find decryption share {election_id} {tally_name} {guardian_id}", 47 | ) 48 | return from_tally_decryption_share_query(query_result) 49 | except Exception as error: 50 | print(sys.exc_info()) 51 | raise HTTPException( 52 | status_code=status.HTTP_404_NOT_FOUND, 53 | detail=f"{election_id} {tally_name} {guardian_id} not found", 54 | ) from error 55 | 56 | 57 | def set_decryption_share( 58 | decryption_share: CiphertextTallyDecryptionShare, settings: Settings = Settings() 59 | ) -> BaseResponse: 60 | try: 61 | with get_repository( 62 | decryption_share.tally_name, DataCollection.DECRYPTION_SHARES, settings 63 | ) as repository: 64 | repository.set(decryption_share.dict()) 65 | return BaseResponse() 66 | except Exception as error: 67 | print(sys.exc_info()) 68 | raise HTTPException( 69 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 70 | detail="set decryption share failed", 71 | ) from error 72 | 73 | 74 | def filter_decryption_shares( 75 | tally_name: str, 76 | filter: Any, 77 | skip: int = 0, 78 | limit: int = 1000, 79 | settings: Settings = Settings(), 80 | ) -> List[CiphertextTallyDecryptionShare]: 81 | try: 82 | with get_repository( 83 | tally_name, DataCollection.DECRYPTION_SHARES, settings 84 | ) as repository: 85 | cursor = repository.find(filter, skip, limit) 86 | decryption_shares: List[CiphertextTallyDecryptionShare] = [] 87 | for item in cursor: 88 | decryption_shares.append(from_tally_decryption_share_query(item)) 89 | return decryption_shares 90 | except Exception as error: 91 | print(sys.exc_info()) 92 | raise HTTPException( 93 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 94 | detail="find decryption shares failed", 95 | ) from error 96 | -------------------------------------------------------------------------------- /app/core/user.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List 2 | import sys 3 | from fastapi import HTTPException, status 4 | 5 | from .client import get_client_id 6 | from .repository import get_repository, DataCollection 7 | from .settings import Settings 8 | from ..api.v1.models import BaseResponse, UserInfo 9 | 10 | __all__ = ["get_user_info", "filter_user_info", "set_user_info", "update_user_info"] 11 | 12 | 13 | def get_user_info(username: str, settings: Settings = Settings()) -> UserInfo: 14 | try: 15 | with get_repository( 16 | get_client_id(), DataCollection.USER_INFO, settings 17 | ) as repository: 18 | query_result = repository.get({"username": username}) 19 | if not query_result: 20 | raise HTTPException( 21 | status_code=status.HTTP_404_NOT_FOUND, 22 | detail=f"Could not find user {username}", 23 | ) 24 | return UserInfo(**query_result) 25 | except Exception as error: 26 | print(sys.exc_info()) 27 | raise HTTPException( 28 | status_code=status.HTTP_404_NOT_FOUND, 29 | detail=f"{username} not found", 30 | ) from error 31 | 32 | 33 | def filter_user_info( 34 | filter: Any, skip: int = 0, limit: int = 1000, settings: Settings = Settings() 35 | ) -> List[UserInfo]: 36 | try: 37 | with get_repository( 38 | get_client_id(), DataCollection.USER_INFO, settings 39 | ) as repository: 40 | cursor = repository.find(filter, skip, limit) 41 | results: List[UserInfo] = [] 42 | for item in cursor: 43 | results.append(item) 44 | return results 45 | except Exception as error: 46 | print(sys.exc_info()) 47 | raise HTTPException( 48 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 49 | detail="filter users failed", 50 | ) from error 51 | 52 | 53 | def set_user_info(user: UserInfo, settings: Settings = Settings()) -> None: 54 | try: 55 | with get_repository( 56 | get_client_id(), DataCollection.USER_INFO, settings 57 | ) as repository: 58 | query_result = repository.get({"username": user.username}) 59 | if not query_result: 60 | repository.set(user.dict()) 61 | else: 62 | raise HTTPException( 63 | status_code=status.HTTP_409_CONFLICT, 64 | detail=f"Already exists {user.username}", 65 | ) 66 | except Exception as error: 67 | print(sys.exc_info()) 68 | raise HTTPException( 69 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 70 | detail="set user info failed", 71 | ) from error 72 | 73 | 74 | def update_user_info(user: UserInfo, settings: Settings = Settings()) -> BaseResponse: 75 | try: 76 | with get_repository( 77 | get_client_id(), DataCollection.GUARDIAN, settings 78 | ) as repository: 79 | query_result = repository.get({"username": user.username}) 80 | if not query_result: 81 | raise HTTPException( 82 | status_code=status.HTTP_404_NOT_FOUND, 83 | detail=f"Could not find user {user.username}", 84 | ) 85 | repository.update({"username": user.username}, user.dict()) 86 | return BaseResponse() 87 | except Exception as error: 88 | print(sys.exc_info()) 89 | raise HTTPException( 90 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 91 | detail="update user info failed", 92 | ) from error 93 | -------------------------------------------------------------------------------- /app/data/plaintext_ballot_ballot-1663ab54-e95f-11eb-bd0c-acde48001122.json: -------------------------------------------------------------------------------- 1 | {"contests": [{"ballot_selections": [{"is_placeholder_selection": false, "object_id": "barchi-hallaren-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "cramer-vuocolo-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "court-blumhardt-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "boone-lian-selection", "vote": 1}, {"is_placeholder_selection": false, "object_id": "patterson-lariviere-selection", "vote": 0}], "object_id": "president-vice-president-contest"}, {"ballot_selections": [{"is_placeholder_selection": false, "object_id": "franz-selection", "vote": 1}, {"is_placeholder_selection": false, "object_id": "harris-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "bargmann-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "abcock-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "williams-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "alpern-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "sharp-althea-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "alexander-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "lee-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "kennedy-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "jackson-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "brown-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "teller-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "ward-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "chandler-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "write-in-selection-governor", "vote": 0}], "object_id": "ozark-governor"}, {"ballot_selections": [{"is_placeholder_selection": false, "object_id": "bainbridge-selection", "vote": 1}, {"is_placeholder_selection": false, "object_id": "hennessey-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "tawa-mary-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "write-in-selection-us-congress-district-7", "vote": 0}], "object_id": "congress-district-7-contest"}, {"ballot_selections": [{"is_placeholder_selection": false, "object_id": "moore-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "white-selection", "vote": 1}, {"is_placeholder_selection": false, "object_id": "smallberries-selection", "vote": 1}, {"is_placeholder_selection": false, "object_id": "warfin-selection", "vote": 1}, {"is_placeholder_selection": false, "object_id": "norberg-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "write-in-selection-2-pismo-beach-school-board", "vote": 0}, {"is_placeholder_selection": false, "object_id": "write-in-selection-3-pismo-beach-school-board", "vote": 0}], "object_id": "pismo-beach-school-board-contest"}, {"ballot_selections": [{"is_placeholder_selection": false, "object_id": "ozark-chief-justice-retain-demergue-affirmative-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "ozark-chief-justice-retain-demergue-negative-selection", "vote": 1}], "object_id": "arlington-chief-justice-retain-demergue"}], "object_id": "ballot-1663ab54-e95f-11eb-bd0c-acde48001122", "style_id": "congress-district-7-arlington-pismo-beach"} -------------------------------------------------------------------------------- /app/data/plaintext_ballot_ballot-1663bbee-e95f-11eb-bd0c-acde48001122.json: -------------------------------------------------------------------------------- 1 | {"contests": [{"ballot_selections": [{"is_placeholder_selection": false, "object_id": "barchi-hallaren-selection", "vote": 1}, {"is_placeholder_selection": false, "object_id": "cramer-vuocolo-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "hildebrand-garritty-selection", "vote": 0}], "object_id": "president-vice-president-contest"}, {"ballot_selections": [{"is_placeholder_selection": false, "object_id": "harris-selection", "vote": 1}, {"is_placeholder_selection": false, "object_id": "bargmann-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "abcock-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "walace-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "windbeck-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "sharp-althea-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "greher-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "mitchell-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "lee-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "teller-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "york-selection", "vote": 0}], "object_id": "ozark-governor"}, {"ballot_selections": [{"is_placeholder_selection": false, "object_id": "soliz-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "keller-selection", "vote": 1}, {"is_placeholder_selection": false, "object_id": "write-in-selection-us-congress-district-5", "vote": 0}], "object_id": "congress-district-5-contest"}, {"ballot_selections": [{"is_placeholder_selection": false, "object_id": "white-selection", "vote": 1}, {"is_placeholder_selection": false, "object_id": "smallberries-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "warfin-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "norberg-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "parks-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "write-in-selection-1-pismo-beach-school-board", "vote": 0}, {"is_placeholder_selection": false, "object_id": "write-in-selection-2-pismo-beach-school-board", "vote": 0}, {"is_placeholder_selection": false, "object_id": "write-in-selection-3-pismo-beach-school-board", "vote": 0}], "object_id": "pismo-beach-school-board-contest"}, {"ballot_selections": [{"is_placeholder_selection": false, "object_id": "ozark-chief-justice-retain-demergue-affirmative-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "ozark-chief-justice-retain-demergue-negative-selection", "vote": 1}], "object_id": "arlington-chief-justice-retain-demergue"}], "object_id": "ballot-1663bbee-e95f-11eb-bd0c-acde48001122", "style_id": "congress-district-5-arlington-pismo-beach"} -------------------------------------------------------------------------------- /app/data/plaintext_ballot_ballot-1663cdc8-e95f-11eb-bd0c-acde48001122.json: -------------------------------------------------------------------------------- 1 | {"contests": [{"ballot_selections": [{"is_placeholder_selection": false, "object_id": "cramer-vuocolo-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "court-blumhardt-selection", "vote": 1}, {"is_placeholder_selection": false, "object_id": "boone-lian-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "hildebrand-garritty-selection", "vote": 0}], "object_id": "president-vice-president-contest"}, {"ballot_selections": [{"is_placeholder_selection": false, "object_id": "franz-selection", "vote": 1}, {"is_placeholder_selection": false, "object_id": "sharp-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "greher-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "lee-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "ash-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "kennedy-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "brown-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "callanann-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "york-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "chandler-selection", "vote": 0}], "object_id": "ozark-governor"}, {"ballot_selections": [{"is_placeholder_selection": false, "object_id": "soliz-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "keller-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "rangel-selection", "vote": 1}, {"is_placeholder_selection": false, "object_id": "argent-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "write-in-selection-us-congress-district-5", "vote": 0}], "object_id": "congress-district-5-contest"}], "object_id": "ballot-1663cdc8-e95f-11eb-bd0c-acde48001122", "style_id": "congress-district-5-lacroix"} -------------------------------------------------------------------------------- /app/data/plaintext_ballot_ballot-1663d854-e95f-11eb-bd0c-acde48001122.json: -------------------------------------------------------------------------------- 1 | {"contests": [{"ballot_selections": [{"is_placeholder_selection": false, "object_id": "cramer-vuocolo-selection", "vote": 1}, {"is_placeholder_selection": false, "object_id": "court-blumhardt-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "patterson-lariviere-selection", "vote": 0}], "object_id": "president-vice-president-contest"}, {"ballot_selections": [{"is_placeholder_selection": false, "object_id": "franz-selection", "vote": 1}, {"is_placeholder_selection": false, "object_id": "bargmann-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "greher-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "alexander-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "lee-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "jackson-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "ward-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "murphy-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "york-selection", "vote": 0}], "object_id": "ozark-governor"}, {"ballot_selections": [{"is_placeholder_selection": false, "object_id": "bainbridge-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "hennessey-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "savoy-selection", "vote": 1}, {"is_placeholder_selection": false, "object_id": "write-in-selection-us-congress-district-7", "vote": 0}], "object_id": "congress-district-7-contest"}, {"ballot_selections": [{"is_placeholder_selection": false, "object_id": "exeter-utility-district-referendum-affirmative-selection", "vote": 1}, {"is_placeholder_selection": false, "object_id": "exeter-utility-district-referendum-selection", "vote": 0}], "object_id": "exeter-utility-district-referendum-contest"}], "object_id": "ballot-1663d854-e95f-11eb-bd0c-acde48001122", "style_id": "congress-district-7-lacroix-exeter"} -------------------------------------------------------------------------------- /app/data/plaintext_ballot_ballot-1663e3e4-e95f-11eb-bd0c-acde48001122.json: -------------------------------------------------------------------------------- 1 | {"contests": [{"ballot_selections": [{"is_placeholder_selection": false, "object_id": "barchi-hallaren-selection", "vote": 1}, {"is_placeholder_selection": false, "object_id": "cramer-vuocolo-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "boone-lian-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "hildebrand-garritty-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "patterson-lariviere-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "write-in-selection-president", "vote": 0}], "object_id": "president-vice-president-contest"}, {"ballot_selections": [{"is_placeholder_selection": false, "object_id": "franz-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "harris-selection", "vote": 1}, {"is_placeholder_selection": false, "object_id": "bargmann-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "abcock-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "steel-loy-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "sharp-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "alexander-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "lee-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "kennedy-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "teller-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "ward-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "murphy-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "newman-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "york-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "chandler-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "write-in-selection-governor", "vote": 0}], "object_id": "ozark-governor"}, {"ballot_selections": [{"is_placeholder_selection": false, "object_id": "soliz-selection", "vote": 1}, {"is_placeholder_selection": false, "object_id": "keller-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "rangel-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "argent-selection", "vote": 0}, {"is_placeholder_selection": false, "object_id": "write-in-selection-us-congress-district-5", "vote": 0}], "object_id": "congress-district-5-contest"}], "object_id": "ballot-1663e3e4-e95f-11eb-bd0c-acde48001122", "style_id": "congress-district-5-lacroix"} -------------------------------------------------------------------------------- /app/main.py: -------------------------------------------------------------------------------- 1 | import logging.config 2 | from typing import Optional 3 | from fastapi import FastAPI, HTTPException 4 | from starlette.middleware.cors import CORSMiddleware 5 | from app.api.v1.models.auth import AuthenticationCredential 6 | 7 | 8 | from app.api.v1.routes import get_v1_routes 9 | from app.api.v1_1.routes import get_v1_1_routes 10 | from app.core.settings import Settings 11 | from app.core.scheduler import get_scheduler 12 | 13 | from app.api.v1.models import UserInfo, UserScope 14 | from app.core import AuthenticationContext, set_auth_credential, set_user_info 15 | 16 | # setup loggers 17 | logging.basicConfig( 18 | level="DEBUG", 19 | format="%(asctime)s %(levelname)-8s %(funcName)s() L%(lineno)-4d %(message)s", 20 | ) 21 | logger = logging.getLogger(__name__) 22 | 23 | 24 | def seed_default_user(settings: Settings = Settings()) -> None: 25 | # TODO: a more secure way to set the default auth credential 26 | hashed_password = AuthenticationContext(settings).get_password_hash( 27 | settings.DEFAULT_ADMIN_PASSWORD 28 | ) 29 | credential = AuthenticationCredential( 30 | username=settings.DEFAULT_ADMIN_USERNAME, hashed_password=hashed_password 31 | ) 32 | user_info = UserInfo( 33 | username=credential.username, 34 | first_name=credential.username, 35 | last_name=credential.username, 36 | scopes=[UserScope.admin], 37 | ) 38 | try: 39 | set_auth_credential(credential, settings) 40 | except HTTPException: 41 | pass 42 | 43 | try: 44 | set_user_info(user_info, settings) 45 | except HTTPException: 46 | pass 47 | 48 | 49 | def get_app(settings: Optional[Settings] = None) -> FastAPI: 50 | if not settings: 51 | settings = Settings() 52 | 53 | web_app = FastAPI( 54 | title=settings.PROJECT_NAME, 55 | openapi_url=f"{settings.API_V1_STR}/openapi.json", 56 | version="1.0.5", 57 | ) 58 | 59 | web_app.state.settings = settings 60 | 61 | logger.info(f"Starting API in {web_app.state.settings.API_MODE} mode") 62 | 63 | # Set all CORS enabled origins 64 | if settings.BACKEND_CORS_ORIGINS: 65 | web_app.add_middleware( 66 | CORSMiddleware, 67 | allow_origins=[str(origin) for origin in settings.BACKEND_CORS_ORIGINS], 68 | allow_credentials=True, 69 | allow_methods=["*"], 70 | allow_headers=["*"], 71 | ) 72 | 73 | seed_default_user(settings) 74 | 75 | v1_routes = get_v1_routes(settings) 76 | web_app.include_router(v1_routes, prefix=settings.API_V1_STR) 77 | v1_1_routes = get_v1_1_routes(settings) 78 | web_app.include_router(v1_1_routes, prefix=settings.API_V1_1_STR) 79 | 80 | return web_app 81 | 82 | 83 | app = get_app() 84 | 85 | 86 | @app.on_event("startup") 87 | def on_startup() -> None: 88 | pass 89 | 90 | 91 | @app.on_event("shutdown") 92 | def on_shutdown() -> None: 93 | # Ensure a clean shutdown of the singleton Scheduler 94 | scheduler = get_scheduler() 95 | scheduler.close() 96 | 97 | 98 | if __name__ == "__main__": 99 | # IMPORTANT: This should only be used to debug the application. 100 | # For normal execution, run `make start`. 101 | # 102 | # To make this work, the PYTHONPATH must be set to the root directory, e.g. 103 | # `PYTHONPATH=. poetry run python ./app/main.py` 104 | # See the VSCode launch configuration for detail. 105 | import uvicorn 106 | import argparse 107 | 108 | parser = argparse.ArgumentParser( 109 | formatter_class=argparse.ArgumentDefaultsHelpFormatter, 110 | ) 111 | parser.add_argument( 112 | "-p", 113 | "--port", 114 | default=8000, 115 | type=int, 116 | help="The port to listen on", 117 | ) 118 | args = parser.parse_args() 119 | 120 | uvicorn.run(app, host="0.0.0.0", port=args.port) 121 | -------------------------------------------------------------------------------- /docker-compose.azure.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | services: 3 | mediator: 4 | build: 5 | context: . 6 | target: prod 7 | image: deploydemoregistry.azurecr.io/electionguard-api-python:latest 8 | container_name: 'electionguard-api-python-mediator' 9 | ports: 10 | - 8000:8000 11 | environment: 12 | API_MODE: "mediator" 13 | QUEUE_MODE: "remote" 14 | STORAGE_MODE: "mongo" 15 | PROJECT_NAME: "ElectionGuard Mediator API" 16 | PORT: 8000 17 | MESSAGEQUEUE_URI: "amqp://guest:guest@electionguard-message-queue:5672" 18 | MONGODB_URI: "mongodb://username:@electionguard-demo.mongo.cosmos.azure.com:10255" 19 | 20 | guardian: 21 | build: 22 | context: . 23 | target: prod 24 | image: deploydemoregistry.azurecr.io/electionguard-api-python:latest 25 | container_name: 'electionguard-api-python-guardian' 26 | ports: 27 | - 8001:8001 28 | environment: 29 | API_MODE: "guardian" 30 | QUEUE_MODE: "remote" 31 | STORAGE_MODE: "mongo" 32 | PROJECT_NAME: "ElectionGuard Guardian API" 33 | PORT: 8001 34 | 35 | messagequeue: 36 | image: rabbitmq:3.8.16-management-alpine 37 | container_name: 'electionguard-message-queue' 38 | expose: 39 | - 5672 40 | - 15672 41 | ports: 42 | - 5672:5672 43 | - 15672:15672 44 | 45 | -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | # This hosts both APIs locally in parallel 2 | # It mounts the codebase within the container and runs the APIs 3 | # with hot reload enabled. 4 | 5 | version: "3.8" 6 | services: 7 | mediator: 8 | build: 9 | context: . 10 | target: dev 11 | volumes: 12 | - "./app:/app/app" 13 | ports: 14 | - 8000:8000 15 | environment: 16 | API_MODE: "mediator" 17 | QUEUE_MODE: "remote" 18 | STORAGE_MODE: "mongo" 19 | PROJECT_NAME: "ElectionGuard Mediator API" 20 | PORT: 8000 21 | MESSAGEQUEUE_URI: "amqp://guest:guest@electionguard-message-queue:5672" 22 | MONGODB_URI: "mongodb://root:example@electionguard-db:27017" 23 | 24 | guardian: 25 | build: 26 | context: . 27 | target: dev 28 | volumes: 29 | - "./app:/app/app" 30 | ports: 31 | - 8001:8001 32 | environment: 33 | API_MODE: "guardian" 34 | QUEUE_MODE: "remote" 35 | STORAGE_MODE: "mongo" 36 | PROJECT_NAME: "ElectionGuard Guardian API" 37 | PORT: 8001 38 | MESSAGEQUEUE_URI: "amqp://guest:guest@electionguard-message-queue:5672" 39 | MONGODB_URI: "mongodb://root:example@electionguard-db:27017" 40 | -------------------------------------------------------------------------------- /docker-compose.support.yml: -------------------------------------------------------------------------------- 1 | # This hosts both APIs locally in parallel 2 | # It mounts the codebase within the container and runs the APIs 3 | # with hot reload enabled. 4 | 5 | version: "3.8" 6 | services: 7 | messagequeue: 8 | image: rabbitmq:3.8.16-management-alpine 9 | container_name: "electionguard-message-queue" 10 | expose: 11 | - 5672 12 | - 15672 13 | ports: 14 | - 5672:5672 15 | - 15672:15672 16 | 17 | mongo: 18 | image: mongo 19 | container_name: "electionguard-db" 20 | restart: always 21 | ports: 22 | - 27017:27017 23 | environment: 24 | MONGO_INITDB_ROOT_USERNAME: root 25 | MONGO_INITDB_ROOT_PASSWORD: example 26 | MONGO_INITDB_DATABASE: BallotData 27 | volumes: 28 | - ./mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js:ro 29 | 30 | mongo-express: 31 | image: mongo-express 32 | restart: always 33 | ports: 34 | - 8181:8081 35 | environment: 36 | ME_CONFIG_MONGODB_SERVER: electionguard-db 37 | ME_CONFIG_MONGODB_ADMINUSERNAME: root 38 | ME_CONFIG_MONGODB_ADMINPASSWORD: example 39 | links: 40 | - mongo 41 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | services: 3 | mediator: 4 | build: 5 | context: . 6 | target: prod 7 | ports: 8 | - 8000:8000 9 | environment: 10 | API_MODE: "mediator" 11 | PROJECT_NAME: "ElectionGuard Mediator API" 12 | PORT: 8000 13 | 14 | guardian: 15 | build: 16 | context: . 17 | target: prod 18 | ports: 19 | - 8001:8001 20 | environment: 21 | API_MODE: "guardian" 22 | PROJECT_NAME: "ElectionGuard Guardian API" 23 | PORT: 8001 24 | -------------------------------------------------------------------------------- /images/electionguard-logo-large.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Election-Tech-Initiative/electionguard-api-python/eb26b45446a8f692709ba59d026832add8bddb43/images/electionguard-logo-large.png -------------------------------------------------------------------------------- /images/electionguard-logo-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Election-Tech-Initiative/electionguard-api-python/eb26b45446a8f692709ba59d026832add8bddb43/images/electionguard-logo-small.png -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: ElectionGuard Web Api 2 | # site_description: 3 | site_author: Microsoft 4 | # google_analytics: 5 | # remote_branch: for gh-deploy to GithubPages 6 | # remote_name: for gh-deploy to Github Pages 7 | copyright: "© Microsoft 2020" 8 | docs_dir: "docs" 9 | site_url: "" 10 | use_directory_urls: false 11 | repo_url: https://github.com/microsoft/electionguard-api-python/ 12 | nav: 13 | - Home: index.md 14 | theme: readthedocs 15 | -------------------------------------------------------------------------------- /mongo-init.js: -------------------------------------------------------------------------------- 1 | db.createCollection('authenticationContext'); 2 | db.createCollection('guardian'); 3 | db.createCollection('keyGuardian'); 4 | db.createCollection('keyCeremony') 5 | db.createCollection('election'); 6 | db.createCollection('manifest'); 7 | db.createCollection('ballotInventory'); 8 | db.createCollection('submittedBallots'); 9 | db.createCollection('ciphertextTally'); 10 | db.createCollection('plaintextTally'); 11 | db.createCollection('decryptionShares'); 12 | db.createCollection('userInfo'); -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "electionguard-api-python" 3 | version = "1.0.5" 4 | description = "ElectionGuard Web Api: Support for e2e verified elections." 5 | license = "MIT" 6 | authors = ["Microsoft Corporation <electionguard@microsoft.com>"] 7 | readme = "README.md" 8 | homepage = "https://microsoft.github.io/electionguard-api-python/" 9 | repository = "https://github.com/microsoft/electionguard-api-python" 10 | documentation = "https://electionguard-api-python.readthedocs.io/" 11 | 12 | [tool.poetry.dependencies] 13 | python = "~=3.9.5" 14 | fastapi = "~0.65" 15 | uvicorn = "~0.11" 16 | pika = "1.2.0" 17 | pymongo = "~3.11.4" 18 | electionguard = "1.2.3" 19 | python-jose = "^3.3.0" 20 | passlib = "^1.7.4" 21 | bcrypt = "^3.2.0" 22 | python-multipart = "^0.0.5" 23 | 24 | 25 | [tool.poetry.dev-dependencies] 26 | black = "21.7b0" 27 | mkdocs = "^1" 28 | mypy = "^0.782" 29 | pylint = "^2" 30 | pytest = "^6" 31 | requests = "^2.24.0" 32 | 33 | [tool.black] 34 | target-version = ['py39'] 35 | include = '\.pyi?$' 36 | 37 | [tool.pylint.'FORMAT'] 38 | max-line-length = 120 39 | 40 | [tool.pylint.'MESSAGES CONTROL'] 41 | disable = ''' 42 | duplicate-code, 43 | fixme, 44 | invalid-name, 45 | logging-fstring-interpolation, 46 | missing-module-docstring, 47 | missing-class-docstring, 48 | missing-function-docstring, 49 | 50 | no-value-for-parameter, 51 | redefined-builtin, 52 | too-few-public-methods, 53 | too-many-arguments, 54 | too-many-branches, 55 | too-many-function-args, 56 | too-many-lines, 57 | too-many-locals, 58 | too-many-nested-blocks, 59 | unnecessary-lambda 60 | ''' 61 | 62 | [build-system] 63 | requires = ["poetry>=0.12"] 64 | build-backend = "poetry.masonry.api" 65 | 66 | [mypy] 67 | python_version = 3.9 68 | warn_return_any = true 69 | warn_unused_configs = true 70 | disallow_untyped_defs = true 71 | ignore_missing_imports = true 72 | follow_imports = "silent" 73 | show_column_numbers = true 74 | 75 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Election-Tech-Initiative/electionguard-api-python/eb26b45446a8f692709ba59d026832add8bddb43/tests/__init__.py -------------------------------------------------------------------------------- /tests/integration/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Election-Tech-Initiative/electionguard-api-python/eb26b45446a8f692709ba59d026832add8bddb43/tests/integration/__init__.py -------------------------------------------------------------------------------- /tests/integration/api_utils.py: -------------------------------------------------------------------------------- 1 | from typing import cast, Any, Dict, Optional 2 | from fastapi.testclient import TestClient 3 | 4 | 5 | BASE_URL = "/api/v1" 6 | 7 | 8 | def send_get_request(client: TestClient, relative_url: str) -> Dict: 9 | response = client.get(f"{BASE_URL}/{relative_url}") 10 | assert 300 > response.status_code >= 200 11 | return cast(Dict, response.json()) 12 | 13 | 14 | def send_put_request( 15 | client: TestClient, relative_url: str, json: Optional[Any] = None 16 | ) -> Dict: 17 | response = client.put(f"{BASE_URL}/{relative_url}", json=json) 18 | assert 300 > response.status_code >= 200 19 | return cast(Dict, response.json()) 20 | 21 | 22 | def send_post_request( 23 | client: TestClient, relative_url: str, json: Optional[Any] = None 24 | ) -> Dict: 25 | response = client.post(f"{BASE_URL}/{relative_url}", json=json) 26 | assert 300 > response.status_code >= 200 27 | return cast(Dict, response.json()) 28 | -------------------------------------------------------------------------------- /tests/integration/conftest.py: -------------------------------------------------------------------------------- 1 | from typing import Generator 2 | import pytest 3 | from app.core.scheduler import get_scheduler 4 | 5 | 6 | @pytest.fixture(scope="session", autouse=True) 7 | def scheduler_lifespan() -> Generator[None, None, None]: 8 | """ 9 | Ensure that the global scheduler singleton is 10 | torn down when tests finish. Otherwise, the test runner will hang 11 | waiting for the scheduler to complete. 12 | """ 13 | yield None 14 | scheduler = get_scheduler() 15 | scheduler.close() 16 | -------------------------------------------------------------------------------- /tests/integration/data/ballot-plaintext-simple.json: -------------------------------------------------------------------------------- 1 | { 2 | "election_id": "hamilton-general-election-simple", 3 | "seed_hash": "B52C5D5946B74F45001E90F5D9BED765C52921F0ED11A5F430F50CDF82EE4756", 4 | "ballots": [ 5 | { 6 | "object_id": "ballot-1663ab54-e95f-11eb-bd0c-acde48001123", 7 | "style_id": "congress-district-7-lacroix", 8 | "contests": [ 9 | { 10 | "object_id": "president-vice-president-contest", 11 | "ballot_selections": [ 12 | { 13 | "is_placeholder_selection": false, 14 | "object_id": "barchi-hallaren-selection", 15 | "vote": 1 16 | }, 17 | { 18 | "is_placeholder_selection": false, 19 | "object_id": "cramer-vuocolo-selection", 20 | "vote": 0 21 | } 22 | ] 23 | } 24 | ] 25 | } 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /tests/integration/data/ballot.json: -------------------------------------------------------------------------------- 1 | { 2 | "style_id": "congress-district-5-hamilton-county", 3 | "contests": [ 4 | { 5 | "ballot_selections": [ 6 | { 7 | "is_placeholder_selection": false, 8 | "object_id": "barchi-hallaren-selection", 9 | "vote": 1 10 | }, 11 | { 12 | "is_placeholder_selection": false, 13 | "object_id": "cramer-vuocolo-selection", 14 | "vote": 0 15 | }, 16 | { 17 | "is_placeholder_selection": false, 18 | "object_id": "court-blumhardt-selection", 19 | "vote": 0 20 | }, 21 | { 22 | "is_placeholder_selection": false, 23 | "object_id": "boone-lian-selection", 24 | "vote": 0 25 | }, 26 | { 27 | "is_placeholder_selection": false, 28 | "object_id": "hildebrand-garritty-selection", 29 | "vote": 0 30 | }, 31 | { 32 | "is_placeholder_selection": false, 33 | "object_id": "patterson-lariviere-selection", 34 | "vote": 0 35 | } 36 | ], 37 | "object_id": "president-vice-president-contest" 38 | }, 39 | { 40 | "ballot_selections": [ 41 | { 42 | "is_placeholder_selection": false, 43 | "object_id": "franz-selection", 44 | "vote": 0 45 | }, 46 | { 47 | "is_placeholder_selection": false, 48 | "object_id": "harris-selection", 49 | "vote": 1 50 | }, 51 | { 52 | "is_placeholder_selection": false, 53 | "object_id": "sharp-selection", 54 | "vote": 0 55 | }, 56 | { 57 | "is_placeholder_selection": false, 58 | "object_id": "alpern-selection", 59 | "vote": 0 60 | }, 61 | { 62 | "is_placeholder_selection": false, 63 | "object_id": "windbeck-selection", 64 | "vote": 0 65 | }, 66 | { 67 | "is_placeholder_selection": false, 68 | "object_id": "greher-selection", 69 | "vote": 0 70 | }, 71 | { 72 | "is_placeholder_selection": false, 73 | "object_id": "alexander-selection", 74 | "vote": 0 75 | }, 76 | { 77 | "is_placeholder_selection": false, 78 | "object_id": "mitchell-selection", 79 | "vote": 0 80 | }, 81 | { 82 | "is_placeholder_selection": false, 83 | "object_id": "lee-selection", 84 | "vote": 0 85 | }, 86 | { 87 | "is_placeholder_selection": false, 88 | "object_id": "ash-selection", 89 | "vote": 0 90 | }, 91 | { 92 | "is_placeholder_selection": false, 93 | "object_id": "brown-selection", 94 | "vote": 0 95 | }, 96 | { 97 | "is_placeholder_selection": false, 98 | "object_id": "murphy-selection", 99 | "vote": 0 100 | }, 101 | { 102 | "is_placeholder_selection": false, 103 | "object_id": "york-selection", 104 | "vote": 0 105 | }, 106 | { 107 | "is_placeholder_selection": false, 108 | "object_id": "write-in-selection-governor", 109 | "vote": 0 110 | } 111 | ], 112 | "object_id": "ozark-governor" 113 | }, 114 | { 115 | "ballot_selections": [ 116 | { 117 | "is_placeholder_selection": false, 118 | "object_id": "soliz-selection", 119 | "vote": 1 120 | }, 121 | { 122 | "is_placeholder_selection": false, 123 | "object_id": "keller-selection", 124 | "vote": 0 125 | }, 126 | { 127 | "is_placeholder_selection": false, 128 | "object_id": "rangel-selection", 129 | "vote": 0 130 | }, 131 | { 132 | "is_placeholder_selection": false, 133 | "object_id": "write-in-selection-us-congress-district-5", 134 | "vote": 0 135 | } 136 | ], 137 | "object_id": "congress-district-5-contest" 138 | } 139 | ], 140 | "object_id": "TEMPLATE_BALLOT" 141 | } -------------------------------------------------------------------------------- /tests/integration/data/test_data.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | from functools import lru_cache 3 | import json 4 | from typing import Any, Dict 5 | 6 | # from electionguard.serializable import read_json_file 7 | 8 | __all__ = ["get_ballot", "get_election_description"] 9 | 10 | _DATA_DIRECTORY = "tests/integration/data" 11 | 12 | 13 | def get_ballot(ballot_id: str) -> Any: 14 | ballot = deepcopy(_get_ballot_template()) 15 | ballot["object_id"] = ballot_id 16 | return ballot 17 | 18 | 19 | def get_election_description() -> Any: 20 | return deepcopy(_get_election_description_template()) 21 | 22 | 23 | def _get_ballot_template() -> Dict: 24 | return _read_json_file(f"{_DATA_DIRECTORY}/ballot.json") 25 | 26 | 27 | @lru_cache 28 | def _get_election_description_template() -> Dict: 29 | return _read_json_file(f"{_DATA_DIRECTORY}/election_description.json") 30 | 31 | 32 | @lru_cache 33 | def _read_json_file(path: str) -> Dict: 34 | with open(path, "r") as file: 35 | return json.load(file) 36 | -------------------------------------------------------------------------------- /tests/integration/guardian_api.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | from typing import Dict, List 3 | from fastapi.testclient import TestClient 4 | 5 | from app.core.settings import ApiMode, StorageMode, Settings 6 | from app.main import get_app 7 | 8 | from . import api_utils 9 | 10 | _api_client = TestClient( 11 | get_app(Settings(API_MODE=ApiMode.GUARDIAN, STORAGE_MODE=StorageMode.LOCAL_STORAGE)) 12 | ) 13 | 14 | 15 | def fetch_public_keys(guardian_id: str) -> Dict: 16 | return api_utils.send_get_request( 17 | _api_client, f"guardian/public-keys?guardian_id={guardian_id}" 18 | ) 19 | 20 | 21 | def create_guardian( 22 | guardian_id: str, 23 | sequence_order: int, 24 | number_of_guardians: int, 25 | quorum: int, 26 | name: str, 27 | ) -> Dict: 28 | request = { 29 | "guardian_id": guardian_id, 30 | "sequence_order": sequence_order, 31 | "number_of_guardians": number_of_guardians, 32 | "quorum": quorum, 33 | "name": name, 34 | } 35 | return api_utils.send_post_request(_api_client, "guardian", request) 36 | 37 | 38 | def create_guardian_backup(guardian: Dict) -> Dict: 39 | auxiliary_public_key = { 40 | "owner_id": guardian["id"], 41 | "sequence_order": guardian["sequence_order"], 42 | "key": guardian["auxiliary_key_pair"]["public_key"], 43 | } 44 | request = { 45 | "guardian_id": guardian["id"], 46 | "quorum": guardian["quorum"], 47 | "election_polynomial": deepcopy(guardian["election_key_pair"]["polynomial"]), 48 | "auxiliary_public_keys": [auxiliary_public_key], 49 | "override_rsa": True, 50 | } 51 | return api_utils.send_post_request(_api_client, "guardian/backup", request) 52 | 53 | 54 | def decrypt_tally_share( 55 | guardian: Dict, encrypted_tally: Dict, description: Dict, context: Dict 56 | ) -> Dict: 57 | request = { 58 | "guardian": guardian, 59 | "encrypted_tally": encrypted_tally, 60 | "description": description, 61 | "context": context, 62 | } 63 | return api_utils.send_post_request(_api_client, "tally/decrypt-share", request) 64 | 65 | 66 | def decrypt_ballot_shares( 67 | encrypted_ballots: List[Dict], guardian: Dict, context: Dict 68 | ) -> Dict: 69 | request = { 70 | "encrypted_ballots": encrypted_ballots, 71 | "guardian": guardian, 72 | "context": context, 73 | } 74 | return api_utils.send_post_request(_api_client, "ballot/decrypt-shares", request) 75 | -------------------------------------------------------------------------------- /tests/integration/mediator_api.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List, Optional 2 | from fastapi.testclient import TestClient 3 | 4 | from app.core.settings import ApiMode, Settings 5 | from app.main import get_app 6 | 7 | from . import api_utils 8 | 9 | _api_client = TestClient(get_app(Settings(API_MODE=ApiMode.MEDIATOR))) 10 | 11 | 12 | def combine_election_keys(key_name: str, election_public_keys: List[Dict]) -> Dict: 13 | """ 14 | Combine the public keys of all guardians into a single ElGamal public key 15 | for use throughout the election 16 | """ 17 | request = {"key_name": key_name, "election_public_keys": election_public_keys} 18 | return api_utils.send_post_request(_api_client, "key/ceremony/combine", request) 19 | 20 | 21 | def get_election(election_id: str) -> Dict: 22 | return api_utils.send_get_request( 23 | _api_client, f"election?election_id={election_id}" 24 | ) 25 | 26 | 27 | def submit_election( 28 | election_id: str, 29 | context: Dict, 30 | manifest: Dict, 31 | ) -> Dict: 32 | """ 33 | Construct an encryption context for use throughout the election to encrypt and decrypt data 34 | """ 35 | request = {"election_id": election_id, "context": context, "manifest": manifest} 36 | return api_utils.send_put_request(_api_client, "election", request) 37 | 38 | 39 | def open_election(election_id: str) -> Dict: 40 | return api_utils.send_post_request( 41 | _api_client, f"election/open?election_id={election_id}" 42 | ) 43 | 44 | 45 | def close_election(election_id: str) -> Dict: 46 | return api_utils.send_post_request( 47 | _api_client, f"election/close?election_id={election_id}" 48 | ) 49 | 50 | 51 | def publish_election(election_id: str) -> Dict: 52 | return api_utils.send_post_request( 53 | _api_client, f"election/publish?election_id={election_id}" 54 | ) 55 | 56 | 57 | def build_election_context( 58 | manifest: Dict, 59 | elgamal_public_key: str, 60 | commitment_hash: str, 61 | number_of_guardians: int, 62 | quorum: int, 63 | ) -> Dict: 64 | """ 65 | Construct an encryption context for use throughout the election to encrypt and decrypt data 66 | """ 67 | request = { 68 | "manifest": manifest, 69 | "elgamal_public_key": elgamal_public_key, 70 | "commitment_hash": commitment_hash, 71 | "number_of_guardians": number_of_guardians, 72 | "quorum": quorum, 73 | } 74 | return api_utils.send_post_request(_api_client, "election/context", request) 75 | 76 | 77 | def get_manifest(manifest_hash: str) -> Dict: 78 | return api_utils.send_get_request( 79 | _api_client, f"manifest?manifest_hash={manifest_hash}" 80 | ) 81 | 82 | 83 | def submit_manifest( 84 | manifest: Dict, 85 | ) -> Dict: 86 | request = { 87 | "manifest": manifest, 88 | } 89 | return api_utils.send_put_request(_api_client, "manifest", request) 90 | 91 | 92 | def validate_manifest( 93 | manifest: Dict, 94 | ) -> Dict: 95 | request = { 96 | "manifest": manifest, 97 | } 98 | return api_utils.send_post_request(_api_client, "manifest/validate", request) 99 | 100 | 101 | def cast_ballot(election_id: str, ballot: Dict, manifest: Dict, context: Dict) -> Dict: 102 | request = { 103 | "election_id": election_id, 104 | "ballots": [ballot], 105 | "manifest": manifest, 106 | "context": context, 107 | } 108 | return api_utils.send_post_request(_api_client, "ballot/cast", request) 109 | 110 | 111 | def spoil_ballot(election_id: str, ballot: Dict, manifest: Dict, context: Dict) -> Dict: 112 | request = { 113 | "election_id": election_id, 114 | "ballots": [ballot], 115 | "manifest": manifest, 116 | "context": context, 117 | } 118 | return api_utils.send_post_request(_api_client, "ballot/spoil", request) 119 | 120 | 121 | def submit_ballot( 122 | election_id: str, ballot: Dict, manifest: Dict, context: Dict 123 | ) -> Dict: 124 | request = { 125 | "election_id": election_id, 126 | "ballots": [ballot], 127 | "manifest": manifest, 128 | "context": context, 129 | } 130 | return api_utils.send_put_request(_api_client, "ballot/submit", request) 131 | 132 | 133 | def validate_ballot(ballot: Dict, manifest: Dict, context: Dict) -> Dict: 134 | request = { 135 | "ballots": ballot, 136 | "manifest": manifest, 137 | "context": context, 138 | } 139 | return api_utils.send_post_request(_api_client, "ballot/validate", request) 140 | 141 | 142 | def encrypt_ballots( 143 | ballots: List[Dict], 144 | seed_hash: str, 145 | nonce: Optional[str], 146 | manifest: Dict, 147 | context: Dict, 148 | ) -> Dict: 149 | 150 | request = { 151 | "ballots": ballots, 152 | "seed_hash": seed_hash, 153 | "nonce": nonce, 154 | "manifest": manifest, 155 | "context": context, 156 | } 157 | return api_utils.send_post_request(_api_client, "ballot/encrypt", request) 158 | 159 | 160 | def start_tally(ballots: List[Dict], description: Dict, context: Dict) -> Dict: 161 | request = { 162 | "ballots": ballots, 163 | "description": description, 164 | "context": context, 165 | } 166 | return api_utils.send_post_request(_api_client, "tally", request) 167 | 168 | 169 | def append_tally( 170 | ballots: List[Dict], encrypted_tally: Dict, description: Dict, context: Dict 171 | ) -> Dict: 172 | request = { 173 | "ballots": ballots, 174 | "encrypted_tally": encrypted_tally, 175 | "description": description, 176 | "context": context, 177 | } 178 | return api_utils.send_post_request(_api_client, "tally/append", request) 179 | 180 | 181 | def decrypt_tally( 182 | encrypted_tally: Dict, shares: Dict[str, Dict], description: Dict, context: Dict 183 | ) -> Dict: 184 | request = { 185 | "encrypted_tally": encrypted_tally, 186 | "shares": shares, 187 | "description": description, 188 | "context": context, 189 | } 190 | return api_utils.send_post_request(_api_client, "tally/decrypt", request) 191 | 192 | 193 | def decrypt_ballots( 194 | encrypted_ballots: List[Dict], 195 | shares: Dict[str, List[Dict]], 196 | context: Dict, 197 | ) -> Dict: 198 | request = { 199 | "encrypted_ballots": encrypted_ballots, 200 | "shares": shares, 201 | "context": context, 202 | } 203 | return api_utils.send_post_request(_api_client, "ballot/decrypt", request) 204 | -------------------------------------------------------------------------------- /tests/postman/.dockerignore: -------------------------------------------------------------------------------- 1 | * 2 | !*.postman_collection.json 3 | -------------------------------------------------------------------------------- /tests/postman/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM postman/newman:5-ubuntu 2 | 3 | # Install dockerize to support waiting for the target APIs to start up 4 | # https://github.com/jwilder/dockerize#ubuntu-images 5 | RUN apt-get update && apt-get install -y wget 6 | ENV DOCKERIZE_VERSION v0.6.1 7 | RUN wget https://github.com/jwilder/dockerize/releases/download/$DOCKERIZE_VERSION/dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz \ 8 | && tar -C /usr/local/bin -xzvf dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz \ 9 | && rm dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz 10 | -------------------------------------------------------------------------------- /tests/postman/Local Environment.postman_environment.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "2885a030-5ece-44af-bb85-c8d2804e4cfd", 3 | "name": "Local", 4 | "values": [ 5 | { 6 | "key": "token", 7 | "value": "", 8 | "type": "any", 9 | "enabled": true 10 | }, 11 | { 12 | "key": "mediator-url", 13 | "value": "http://localhost:8000", 14 | "type": "default", 15 | "enabled": true 16 | }, 17 | { 18 | "key": "version", 19 | "value": "v1", 20 | "type": "default", 21 | "enabled": true 22 | }, 23 | { 24 | "key": "guardian-url", 25 | "value": "http://localhost:8001", 26 | "type": "default", 27 | "enabled": true 28 | }, 29 | { 30 | "key": "admin-username", 31 | "value": "default", 32 | "type": "default", 33 | "enabled": true 34 | }, 35 | { 36 | "key": "admin-password", 37 | "value": "<this is a default value and should be changed>", 38 | "type": "default", 39 | "enabled": true 40 | } 41 | ], 42 | "_postman_variable_scope": "environment", 43 | "_postman_exported_at": "2022-03-16T14:41:09.171Z", 44 | "_postman_exported_using": "Postman/9.14.14" 45 | } -------------------------------------------------------------------------------- /tests/postman/docker-compose.yml: -------------------------------------------------------------------------------- 1 | # This will spin up multiple parallel containers: 2 | # - One for each API under test 3 | # - one for each infrastructure component 4 | # - One to house the test runner 5 | # 6 | # The test runner will wait for both API containers to run, and validate that 7 | # the APIs have started by checking their ping endpoints. 8 | # 9 | # This should be run with the --build and --abort-on-container-exit flags 10 | 11 | version: "3.8" 12 | services: 13 | messagequeue: 14 | image: rabbitmq:3.8.16-management-alpine 15 | container_name: "postman-electionguard-message-queue" 16 | expose: 17 | - 5672 18 | - 15672 19 | ports: 20 | - 5672:5672 21 | - 15672:15672 22 | 23 | mediator: 24 | build: 25 | context: ../.. 26 | target: prod 27 | expose: 28 | - 80 29 | environment: 30 | API_MODE: "mediator" 31 | QUEUE_MODE: "remote" 32 | STORAGE_MODE: "mongo" 33 | PROJECT_NAME: "ElectionGuard Mediator API" 34 | PORT: 80 35 | MESSAGEQUEUE_URI: "amqp://guest:guest@postman-electionguard-message-queue:5672" 36 | MONGODB_URI: "mongodb://root:example@postman-electionguard-db:27017" 37 | 38 | guardian: 39 | build: 40 | context: ../.. 41 | target: prod 42 | expose: 43 | - 80 44 | environment: 45 | API_MODE: "guardian" 46 | QUEUE_MODE: "remote" 47 | STORAGE_MODE: "mongo" 48 | PROJECT_NAME: "ElectionGuard Guardian API" 49 | PORT: 80 50 | MESSAGEQUEUE_URI: "amqp://guest:guest@postman-electionguard-message-queue:5672" 51 | MONGODB_URI: "mongodb://root:example@postman-electionguard-db:27017" 52 | 53 | mongo: 54 | image: mongo 55 | container_name: "postman-electionguard-db" 56 | restart: always 57 | ports: 58 | - 27017:27017 59 | environment: 60 | MONGO_INITDB_ROOT_USERNAME: root 61 | MONGO_INITDB_ROOT_PASSWORD: example 62 | MONGO_INITDB_DATABASE: BallotData 63 | volumes: 64 | - ./mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js:ro 65 | 66 | mongo-express: 67 | image: mongo-express 68 | restart: always 69 | ports: 70 | - 8081:8081 71 | environment: 72 | ME_CONFIG_MONGODB_ADMINUSERNAME: root 73 | ME_CONFIG_MONGODB_ADMINPASSWORD: example 74 | links: 75 | - "mongo" 76 | 77 | test-runner: 78 | build: 79 | context: . 80 | depends_on: 81 | - mediator 82 | - guardian 83 | volumes: 84 | - ".:/tests" 85 | entrypoint: dockerize 86 | command: 87 | -wait http://guardian/api/v1/ping -wait http://mediator/api/v1/ping -timeout 10s 88 | bash -c "newman run /tests/*.postman_collection.json --timeout-request 60000 --env-var guardian-url=http://guardian --env-var mediator-url=http://mediator" 89 | --------------------------------------------------------------------------------