├── .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: "✨ "
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 |
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 |
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 = ""
52 | AUTH_ACCESS_TOKEN_EXPIRE_MINUTES = 30
53 | DEFAULT_ADMIN_USERNAME = "default"
54 | DEFAULT_ADMIN_PASSWORD = ""
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 "]
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": "",
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 |
--------------------------------------------------------------------------------