├── .github └── workflows │ └── lint-and-test.yml ├── .gitignore ├── LICENSE.txt ├── README.md ├── build_dist.sh ├── clean.sh ├── cwa_qr ├── __init__.py ├── cwa.proto ├── cwa.py ├── cwa_pb2.py ├── generate-cwa_pb2.sh ├── poster.py ├── poster │ ├── landscape.svg │ └── portrait.svg ├── rollover.py ├── seed.py ├── test_cwa.py ├── test_poster.py ├── test_rollover.py └── test_seed.py ├── example.py ├── example_full.py ├── lint.sh ├── pyproject.toml ├── setup.py ├── test.sh └── upload_dist.sh /.github/workflows/lint-and-test.yml: -------------------------------------------------------------------------------- 1 | name: Lint and Test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python-version: ["3.7", "3.8", "3.9", "3.10"] 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v2 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | 20 | - name: Install dependencies 21 | run: | 22 | python -m pip install --upgrade pip 23 | pip install pytest flake8 24 | python setup.py install 25 | 26 | - name: Lint with flake8 27 | run: ./lint.sh 28 | 29 | - name: Test with pytest 30 | run: ./test.sh 31 | 32 | - name: Run the example-files 33 | run: | 34 | ./example.py 35 | ./example_full.py 36 | -------------------------------------------------------------------------------- /.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 | # Example-Files 141 | /example.png 142 | /example.svg 143 | /poster.svg 144 | /poster.png 145 | 146 | # IDEs 147 | /.idea 148 | 149 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021 Peter Körner 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Python implementation of the Corona-Warn-App (CWA) Event Registration 2 | =================================================================== 3 | 4 | [![GitHub](https://img.shields.io/github/license/MaZderMind/cwa-qr)](https://github.com/MaZderMind/cwa-qr/blob/main/LICENSE.txt) 5 | [![PyPI](https://img.shields.io/pypi/v/cwa-qr)](https://pypi.org/project/cwa-qr/) 6 | 7 | This is an implementation of the Protocol used to generate event and location QR codes for the Corona-Warn-App (CWA) as described in [ 8 | Corona-Warn-App: Documentation – Event Registration - Summary](https://github.com/corona-warn-app/cwa-documentation/blob/master/event_registration.md). 9 | 10 | **This is not an official implementation – use it at your own risk** (as far as that's possible, these days…). 11 | 12 | State 13 | ----- 14 | The Interface described in the Document is implemented, the undocumented pieces (Public Key Value, Seed Length, Versions etc.) have been taken from the Open Source iOS Client Application. As far as I know the interface has been fully implemented, but without an actual positive Corona Test there is no way to do an End-to-End verification. 15 | 16 | Usage 17 | ----- 18 | Use as follows: 19 | 20 | ```py 21 | #!/usr/bin/env python3 22 | 23 | from datetime import datetime, time, timezone 24 | 25 | import cwa_qr 26 | 27 | # Construct Event-Descriptor 28 | event_description = cwa_qr.CwaEventDescription() 29 | event_description.location_description = 'Zuhause' 30 | event_description.location_address = 'Gau-Odernheim' 31 | event_description.start_date_time = datetime(2021, 4, 25, 8, 0).astimezone(timezone.utc) 32 | event_description.end_date_time = datetime(2021, 4, 25, 18, 0).astimezone(timezone.utc) 33 | event_description.location_type = cwa_qr.CwaLocation.permanent_workplace 34 | event_description.default_check_in_length_in_minutes = 4 * 60 35 | 36 | # Renew QR-Code every night at 4:00 37 | seed_date = event_description.seed = cwa_qr.rollover_date(datetime.now(), time(4, 0)) 38 | print("seedDate", seed_date) 39 | event_description.seed = "Some Secret" + str(seed_date) 40 | 41 | # Generate QR-Code 42 | qr = cwa_qr.generate_qr_code(event_description) 43 | 44 | # Save as PNG 45 | img = qr.make_image(fill_color="black", back_color="white") 46 | img.save('example.png') 47 | print("generated example.png") 48 | ``` 49 | 50 | See [example_full.py](example_full.py) for an example using all features. 51 | 52 | CwaEventDescription 53 | ------------------- 54 | - `location_description`: Description of the Location, Optional, String, max 100 Characters 55 | - `location_address`: Address of the Location, Optional, String, max 100 Characters 56 | - `start_date_time`: Start of the Event, Optional, datetime in UTC 57 | - `end_date_time`: End of the Event, Optional, datetime in UTC 58 | **Caution**, QR-Codes generated with different start/end times will have different Event-IDs and not warn users that 59 | have checked in with the other Code. **Do not use `datetime.now()`** for start/end-date. For repeating Events use 60 | `cwa_qr.rollover_date` to get a defined rollover. 61 | - `location_type`: Type of the Location, Optional, one of 62 | - `cwa.CwaLocation.unspecified` 63 | - `cwa.CwaLocation.permanent_other` 64 | - `cwa.CwaLocation.temporary_other` 65 | - `cwa.CwaLocation.permanent_retail` 66 | - `cwa.CwaLocation.permanent_food_service` 67 | - `cwa.CwaLocation.permanent_craft` 68 | - `cwa.CwaLocation.permanent_workplace` 69 | - `cwa.CwaLocation.permanent_educational_institution` 70 | - `cwa.CwaLocation.permanent_public_building` 71 | - `cwa.CwaLocation.temporary_cultural_event` 72 | - `cwa.CwaLocation.temporary_club_activity` 73 | - `cwa.CwaLocation.temporary_private_event` 74 | - `cwa.CwaLocation.temporary_worship_service` 75 | - `default_check_in_length_in_minutes`: Default Check-out time in minutes, Optional 76 | - `seed`: Seed to rotate the QR-Code, Optional, `[str, bytes, int, float, date, datetime]` or `None` (Default). 77 | **Use with caution & read below!** If unsure, leave blank. 78 | 79 | Rotating QR-Codes 80 | ----------------- 81 | From the [Documentation](https://github.com/corona-warn-app/cwa-documentation/blob/master/event_registration.md): 82 | > Profiling of Venues 83 | > 84 | > An adversary can collect this information for a single venue by scanning the QR code and extracting and storing the 85 | > data. To mitigate the risk, CWA encourages owners to regularly generate new QR codes for their venues. The more 86 | > frequent QR codes are updated, the more difficult it is to keep a central database with venue data up-to-date. 87 | > **However**, a new QR code should only be generated **when no visitor is at the event or location**, because 88 | > visitors can only warn each other **with the same QR code**. 89 | 90 | From an Application-Developers point of view, special care must be taken to decide if and when QR codes should be 91 | changed. A naive approach, i.e. changing the QR-Code on every call, would render the complete Warning-Chain totally 92 | useless **without anyone noticing**. Therefore, the Default of this Library as of 2021/04/26 is to **not seed the 93 | QR-Codes with random values**. This results in every QR-Code being generated without an explicit Seed to be identical, 94 | which minimizes the Risk of having QR-Codes that do not warn users as expected at the increased risk of profiling of 95 | Venues. 96 | 97 | As an Application-Developer you are encouraged to **ask you user if and when they want their QR-Codes to change** and 98 | explain to them that they should only rotate their Codes **when they are sure that nobody is at the location or in the 99 | venue** for at least 30 Minutes, to allow airborne particles to settle or get filtered out. Do **not make assumptions** 100 | regarding a good time to rotate QR-Codes (i.e. always at 4:00 am) because they will fail so warn people in some 101 | important Situations (nightclubs, hotels, night-shift working) **without anyone noticing**. 102 | 103 | To disable rotation of QR-Codes, specify None as the Seed (Default behaviour). 104 | 105 | The Library also gives you a utility to allow rotating QR-Codes at a given time of the day. Please make 106 | sure to also integrate some kind of Secret into the seed, to prevent an adversary from calculating future QR-Codes. 107 | The Secret *must stay constant* over time, or the resulting QR-Codes will not correctly trigger warnings. 108 | 109 | ```py 110 | import io 111 | from datetime import datetime, time 112 | 113 | import cwa_qr 114 | 115 | # Construct Event-Descriptor 116 | event_description = cwa_qr.CwaEventDescription() 117 | # … 118 | seed_date = cwa_qr.rollover_date(datetime.now(), time(4, 0)) 119 | event_description.seed = "Some Secret" + str(seed_date) 120 | ``` 121 | 122 | this will keep the date-based seed until 4:00 am on the next day and only then roll over to the next day. 123 | See [test_rollover.py](cwa_qr/test_rollover.py) for an in-depth look at the rollover code. 124 | 125 | Posters 126 | ------- 127 | This Library has Support for compositing the QR-Code with a Poster, explaining its usage: 128 | "Checken Sie ein, stoppen Sie das Virus". The Poster-Functionality works by composing the QR-Code SVG with the 129 | Poster-SVG and thus only supports SVG-Output. Both Landscape and Portrait-Posters are supported. 130 | 131 | You can use [pyrsvg](https://www.cairographics.org/cookbook/pyrsvg/) if you need to convert the poster to a PNG 132 | or [svglib](https://pypi.org/project/svglib/) to convert it to a PDF. 133 | 134 | See [example_full.py](example_full.py) for an Example on how to use the Poster-Functionality. 135 | 136 | Python 2/3 137 | ---------- 138 | This library supports Python 3.7+, however there is a backport to Python 2 available at https://github.com/MaZderMind/cwa-qr/tree/py2 139 | -------------------------------------------------------------------------------- /build_dist.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -ex 3 | 4 | ./clean.sh 5 | ./env/bin/pip install build 6 | ./env/bin/python -m build 7 | 8 | ls -la ./dist 9 | -------------------------------------------------------------------------------- /clean.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -ex 3 | 4 | rm -rf .pytest_cache cwa_qr.egg-info dist 5 | -------------------------------------------------------------------------------- /cwa_qr/__init__.py: -------------------------------------------------------------------------------- 1 | from .cwa import (CwaEventDescription, CwaLocation, generate_payload, 2 | generate_qr_code, generate_url, lowlevel) 3 | from .poster import CwaPoster, generate_poster 4 | 5 | from .rollover import rollover_date 6 | 7 | __all__ = [ 8 | "CwaEventDescription", 9 | "CwaLocation", 10 | "CwaPoster", 11 | "generate_poster", 12 | "generate_qr_code", 13 | "generate_url", 14 | "generate_payload", 15 | "rollover_date", 16 | "lowlevel", 17 | ] 18 | -------------------------------------------------------------------------------- /cwa_qr/cwa.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | message QRCodePayload { 4 | uint32 version = 1; 5 | TraceLocation locationData = 2; 6 | CrowdNotifierData crowdNotifierData = 3; 7 | // byte sequence of CWALocationData 8 | bytes countryData = 4; 9 | } 10 | 11 | message TraceLocation { 12 | uint32 version = 1; 13 | // max. 100 characters 14 | string description = 2; 15 | // max. 100 characters 16 | string address = 3; 17 | 18 | // UNIX timestamp (in seconds) 19 | uint64 startTimestamp = 5; 20 | // UNIX timestamp (in seconds) 21 | uint64 endTimestamp = 6; 22 | } 23 | 24 | message CrowdNotifierData { 25 | uint32 version = 1; 26 | bytes publicKey = 2; 27 | bytes cryptographicSeed = 3; 28 | uint32 type = 4; // exact semantic tbd 29 | } 30 | 31 | enum TraceLocationType { 32 | LOCATION_TYPE_UNSPECIFIED = 0; 33 | LOCATION_TYPE_PERMANENT_OTHER = 1; 34 | LOCATION_TYPE_TEMPORARY_OTHER = 2; 35 | 36 | LOCATION_TYPE_PERMANENT_RETAIL = 3; 37 | LOCATION_TYPE_PERMANENT_FOOD_SERVICE = 4; 38 | LOCATION_TYPE_PERMANENT_CRAFT = 5; 39 | LOCATION_TYPE_PERMANENT_WORKPLACE = 6; 40 | LOCATION_TYPE_PERMANENT_EDUCATIONAL_INSTITUTION = 7; 41 | LOCATION_TYPE_PERMANENT_PUBLIC_BUILDING = 8; 42 | 43 | LOCATION_TYPE_TEMPORARY_CULTURAL_EVENT = 9; 44 | LOCATION_TYPE_TEMPORARY_CLUB_ACTIVITY = 10; 45 | LOCATION_TYPE_TEMPORARY_PRIVATE_EVENT = 11; 46 | LOCATION_TYPE_TEMPORARY_WORSHIP_SERVICE = 12; 47 | 48 | } 49 | 50 | message CWALocationData { 51 | uint32 version = 1; 52 | TraceLocationType type = 2; 53 | uint32 defaultCheckInLengthInMinutes = 3; 54 | } 55 | -------------------------------------------------------------------------------- /cwa_qr/cwa.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import enum 3 | from datetime import date, datetime 4 | 5 | from typing import Optional, Union 6 | 7 | import qrcode 8 | 9 | from . import cwa_pb2 as lowlevel 10 | from . import seed 11 | 12 | PUBLIC_KEY_STR = 'gwLMzE153tQwAOf2MZoUXXfzWTdlSpfS99iZffmcmxOG9njSK4RTimFOFwDh6t0Tyw8XR01ugDYjtuKwj' \ 13 | 'juK49Oh83FWct6XpefPi9Skjxvvz53i9gaMmUEc96pbtoaA' 14 | 15 | PUBLIC_KEY = base64.standard_b64decode(PUBLIC_KEY_STR.encode('ascii')) 16 | 17 | 18 | class CwaLocation(enum.IntEnum): 19 | unspecified = lowlevel.LOCATION_TYPE_UNSPECIFIED 20 | permanent_other = lowlevel.LOCATION_TYPE_PERMANENT_OTHER 21 | temporary_other = lowlevel.LOCATION_TYPE_TEMPORARY_OTHER 22 | permanent_retail = lowlevel.LOCATION_TYPE_PERMANENT_RETAIL 23 | permanent_food_service = lowlevel.LOCATION_TYPE_PERMANENT_FOOD_SERVICE 24 | permanent_craft = lowlevel.LOCATION_TYPE_PERMANENT_CRAFT 25 | permanent_workplace = lowlevel.LOCATION_TYPE_PERMANENT_WORKPLACE 26 | permanent_educational_institution = lowlevel.LOCATION_TYPE_PERMANENT_EDUCATIONAL_INSTITUTION 27 | permanent_public_building = lowlevel.LOCATION_TYPE_PERMANENT_PUBLIC_BUILDING 28 | temporary_cultural_event = lowlevel.LOCATION_TYPE_TEMPORARY_CULTURAL_EVENT 29 | temporary_club_activity = lowlevel.LOCATION_TYPE_TEMPORARY_CLUB_ACTIVITY 30 | temporary_private_event = lowlevel.LOCATION_TYPE_TEMPORARY_PRIVATE_EVENT 31 | temporary_worship_service = lowlevel.LOCATION_TYPE_TEMPORARY_WORSHIP_SERVICE 32 | 33 | 34 | class CwaEventDescription(object): 35 | def __init__(self): 36 | """Description of the Location, Required, String, max 100 Characters""" 37 | self.location_description: Optional[str] = None 38 | 39 | """Address of the Location, Required, String, max 100 Characters""" 40 | self.location_address: Optional[str] = None 41 | 42 | """Start of the Event, Optional, datetime in UTC""" 43 | self.start_date_time: Optional[datetime] = None 44 | 45 | """End of the Event, Optional, datetime in UTC""" 46 | self.end_date_time: Optional[datetime] = None 47 | 48 | """Type of the Location, Optional 49 | 50 | one of 51 | - cwa_qr.lowlevel.LOCATION_TYPE_UNSPECIFIED = 0 52 | - cwa_qr.lowlevel.LOCATION_TYPE_PERMANENT_OTHER = 1 53 | - cwa_qr.lowlevel.LOCATION_TYPE_TEMPORARY_OTHER = 2 54 | - cwa_qr.lowlevel.LOCATION_TYPE_PERMANENT_RETAIL = 3 55 | - cwa_qr.lowlevel.LOCATION_TYPE_PERMANENT_FOOD_SERVICE = 4 56 | - cwa_qr.lowlevel.LOCATION_TYPE_PERMANENT_CRAFT = 5 57 | - cwa_qr.lowlevel.LOCATION_TYPE_PERMANENT_WORKPLACE = 6 58 | - cwa_qr.lowlevel.LOCATION_TYPE_PERMANENT_EDUCATIONAL_INSTITUTION = 7 59 | - cwa_qr.lowlevel.LOCATION_TYPE_PERMANENT_PUBLIC_BUILDING = 8 60 | - cwa_qr.lowlevel.LOCATION_TYPE_TEMPORARY_CULTURAL_EVENT = 9 61 | - cwa_qr.lowlevel.LOCATION_TYPE_TEMPORARY_CLUB_ACTIVITY = 10 62 | - cwa_qr.lowlevel.LOCATION_TYPE_TEMPORARY_PRIVATE_EVENT = 11 63 | - cwa_qr.lowlevel.LOCATION_TYPE_TEMPORARY_WORSHIP_SERVICE = 12 64 | """ 65 | self.location_type: Optional[int] = None 66 | 67 | """Default Checkout-Time im Minutes, Optional""" 68 | self.default_check_in_length_in_minutes: Optional[int] = None 69 | 70 | """Seed to rotate the QR-Code, Optional, [str, bytes, int, float, date, datetime] or None (Default) 71 | 72 | Use with caution & read the Section about *Rotating QR-Codes* in the README first! If unsure, leave blank. 73 | """ 74 | self.seed: Union[str, bytes, int, float, date, datetime, None] = None 75 | 76 | 77 | def generate_payload(event_description: CwaEventDescription) -> lowlevel.QRCodePayload: 78 | payload = lowlevel.QRCodePayload() 79 | payload.version = 1 80 | 81 | payload.locationData.version = 1 82 | payload.locationData.description = event_description.location_description 83 | payload.locationData.address = event_description.location_address 84 | payload.locationData.startTimestamp = int(event_description.start_date_time.timestamp()) if \ 85 | event_description.start_date_time else 0 86 | payload.locationData.endTimestamp = int(event_description.end_date_time.timestamp()) if \ 87 | event_description.end_date_time else 0 88 | 89 | payload.crowdNotifierData.version = 1 90 | payload.crowdNotifierData.publicKey = PUBLIC_KEY 91 | payload.crowdNotifierData.cryptographicSeed = seed.construct_seed(event_description.seed) 92 | 93 | cwa_location_data = lowlevel.CWALocationData() 94 | cwa_location_data.version = 1 95 | cwa_location_data.type = event_description.location_type if \ 96 | event_description.location_type is not None else lowlevel.LOCATION_TYPE_UNSPECIFIED 97 | cwa_location_data.defaultCheckInLengthInMinutes = event_description.default_check_in_length_in_minutes if \ 98 | event_description.default_check_in_length_in_minutes is not None else 0 99 | 100 | payload.countryData = cwa_location_data.SerializeToString() 101 | return payload 102 | 103 | 104 | def generate_url(event_description: CwaEventDescription) -> str: 105 | payload = generate_payload(event_description) 106 | serialized = payload.SerializeToString() 107 | encoded = base64.urlsafe_b64encode(serialized) 108 | url = 'https://e.coronawarn.app?v=1#' + encoded.decode('ascii') 109 | 110 | return url.rstrip('=') 111 | 112 | 113 | def generate_qr_code(event_description: CwaEventDescription) -> qrcode.QRCode: 114 | qr = qrcode.QRCode() 115 | qr.add_data(generate_url(event_description)) 116 | qr.make(fit=True) 117 | 118 | return qr 119 | -------------------------------------------------------------------------------- /cwa_qr/cwa_pb2.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by the protocol buffer compiler. DO NOT EDIT! 3 | # source: cwa.proto 4 | """Generated protocol buffer code.""" 5 | from google.protobuf.internal import builder as _builder 6 | from google.protobuf import descriptor as _descriptor 7 | from google.protobuf import descriptor_pool as _descriptor_pool 8 | from google.protobuf import symbol_database as _symbol_database 9 | # @@protoc_insertion_point(imports) 10 | 11 | _sym_db = _symbol_database.Default() 12 | 13 | 14 | 15 | 16 | DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\tcwa.proto\"\x8a\x01\n\rQRCodePayload\x12\x0f\n\x07version\x18\x01 \x01(\r\x12$\n\x0clocationData\x18\x02 \x01(\x0b\x32\x0e.TraceLocation\x12-\n\x11\x63rowdNotifierData\x18\x03 \x01(\x0b\x32\x12.CrowdNotifierData\x12\x13\n\x0b\x63ountryData\x18\x04 \x01(\x0c\"t\n\rTraceLocation\x12\x0f\n\x07version\x18\x01 \x01(\r\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12\x0f\n\x07\x61\x64\x64ress\x18\x03 \x01(\t\x12\x16\n\x0estartTimestamp\x18\x05 \x01(\x04\x12\x14\n\x0c\x65ndTimestamp\x18\x06 \x01(\x04\"`\n\x11\x43rowdNotifierData\x12\x0f\n\x07version\x18\x01 \x01(\r\x12\x11\n\tpublicKey\x18\x02 \x01(\x0c\x12\x19\n\x11\x63ryptographicSeed\x18\x03 \x01(\x0c\x12\x0c\n\x04type\x18\x04 \x01(\r\"k\n\x0f\x43WALocationData\x12\x0f\n\x07version\x18\x01 \x01(\r\x12 \n\x04type\x18\x02 \x01(\x0e\x32\x12.TraceLocationType\x12%\n\x1d\x64\x65\x66\x61ultCheckInLengthInMinutes\x18\x03 \x01(\r*\xa1\x04\n\x11TraceLocationType\x12\x1d\n\x19LOCATION_TYPE_UNSPECIFIED\x10\x00\x12!\n\x1dLOCATION_TYPE_PERMANENT_OTHER\x10\x01\x12!\n\x1dLOCATION_TYPE_TEMPORARY_OTHER\x10\x02\x12\"\n\x1eLOCATION_TYPE_PERMANENT_RETAIL\x10\x03\x12(\n$LOCATION_TYPE_PERMANENT_FOOD_SERVICE\x10\x04\x12!\n\x1dLOCATION_TYPE_PERMANENT_CRAFT\x10\x05\x12%\n!LOCATION_TYPE_PERMANENT_WORKPLACE\x10\x06\x12\x33\n/LOCATION_TYPE_PERMANENT_EDUCATIONAL_INSTITUTION\x10\x07\x12+\n\'LOCATION_TYPE_PERMANENT_PUBLIC_BUILDING\x10\x08\x12*\n&LOCATION_TYPE_TEMPORARY_CULTURAL_EVENT\x10\t\x12)\n%LOCATION_TYPE_TEMPORARY_CLUB_ACTIVITY\x10\n\x12)\n%LOCATION_TYPE_TEMPORARY_PRIVATE_EVENT\x10\x0b\x12+\n\'LOCATION_TYPE_TEMPORARY_WORSHIP_SERVICE\x10\x0c\x62\x06proto3') 17 | 18 | _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) 19 | _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'cwa_pb2', globals()) 20 | if _descriptor._USE_C_DESCRIPTORS == False: 21 | 22 | DESCRIPTOR._options = None 23 | _TRACELOCATIONTYPE._serialized_start=480 24 | _TRACELOCATIONTYPE._serialized_end=1025 25 | _QRCODEPAYLOAD._serialized_start=14 26 | _QRCODEPAYLOAD._serialized_end=152 27 | _TRACELOCATION._serialized_start=154 28 | _TRACELOCATION._serialized_end=270 29 | _CROWDNOTIFIERDATA._serialized_start=272 30 | _CROWDNOTIFIERDATA._serialized_end=368 31 | _CWALOCATIONDATA._serialized_start=370 32 | _CWALOCATIONDATA._serialized_end=477 33 | # @@protoc_insertion_point(module_scope) 34 | -------------------------------------------------------------------------------- /cwa_qr/generate-cwa_pb2.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | protoc --python_out=. cwa.proto 3 | -------------------------------------------------------------------------------- /cwa_qr/poster.py: -------------------------------------------------------------------------------- 1 | import io 2 | import os 3 | 4 | from svgutils import transform as svg_utils 5 | import qrcode.image.svg 6 | 7 | from cwa_qr import generate_qr_code, CwaEventDescription 8 | 9 | 10 | class CwaPoster(object): 11 | POSTER_PORTRAIT = 'portrait' 12 | POSTER_LANDSCAPE = 'landscape' 13 | 14 | TRANSLATIONS = { 15 | POSTER_PORTRAIT: { 16 | 'file': 'poster/portrait.svg', 17 | 'x': 80, 18 | 'y': 60, 19 | 'scale': 6 20 | }, 21 | POSTER_LANDSCAPE: { 22 | 'file': 'poster/landscape.svg', 23 | 'x': 42, 24 | 'y': 120, 25 | 'scale': 4.8 26 | } 27 | } 28 | 29 | 30 | def generate_poster(event_description: CwaEventDescription, template: CwaPoster) -> svg_utils.SVGFigure: 31 | qr = generate_qr_code(event_description) 32 | svg = qr.make_image(image_factory=qrcode.image.svg.SvgPathImage) 33 | svg_bytes = io.BytesIO() 34 | svg.save(svg_bytes) 35 | 36 | poster = svg_utils.fromfile('{}/{}'.format( 37 | os.path.dirname(os.path.abspath(__file__)), 38 | CwaPoster.TRANSLATIONS[template]['file'] 39 | )) 40 | overlay = svg_utils.fromstring(svg_bytes.getvalue().decode('UTF-8')).getroot() 41 | overlay.moveto( 42 | CwaPoster.TRANSLATIONS[template]['x'], 43 | CwaPoster.TRANSLATIONS[template]['y'], 44 | CwaPoster.TRANSLATIONS[template]['scale'] 45 | ) 46 | poster.append([overlay]) 47 | return poster 48 | -------------------------------------------------------------------------------- /cwa_qr/poster/portrait.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 363 | 364 | 365 | "/> 366 | 367 | 368 | 369 | 370 | 371 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | 384 | 385 | 386 | 387 | 388 | 389 | 390 | 391 | 392 | 393 | 394 | 395 | 396 | 397 | 398 | 399 | 400 | 401 | 402 | 403 | 404 | 407 | 408 | 409 | "/> 410 | 411 | 412 | 413 | 414 | 415 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | 427 | 428 | 429 | 430 | 431 | 432 | 433 | 434 | 435 | 436 | 437 | 438 | 439 | 440 | 441 | 442 | 443 | 444 | 445 | 446 | 447 | 448 | 449 | 450 | 451 | 454 | 455 | 456 | "/> 457 | 458 | 459 | 460 | 461 | 462 | 468 | 469 | 470 | 471 | 472 | 473 | 474 | 475 | 476 | 477 | 478 | 479 | 480 | 481 | 482 | 483 | 484 | 485 | 486 | 487 | 488 | 489 | 490 | 491 | 492 | 493 | 494 | 495 | 496 | 497 | 498 | 499 | 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 509 | 510 | 511 | 512 | 513 | 514 | 515 | 516 | 517 | 518 | 519 | 520 | 521 | 522 | 523 | 524 | 525 | 526 | 527 | 528 | 529 | 530 | 531 | 532 | 533 | 534 | 535 | 536 | 537 | 538 | 539 | 540 | 541 | 542 | 543 | 544 | 545 | 546 | 547 | 548 | 549 | 550 | 551 | 552 | 553 | 554 | 555 | 556 | 557 | 558 | 562 | 563 | 564 | "/> 565 | 566 | 567 | 568 | 569 | 570 | 575 | 576 | 577 | 578 | 579 | 580 | 581 | 582 | 583 | 584 | 585 | 586 | 587 | 588 | 589 | 592 | 593 | 594 | "/> 595 | 596 | 597 | 598 | 599 | 600 | 607 | 608 | 609 | 610 | 611 | 612 | 613 | 614 | 615 | 616 | 617 | 618 | 1140 | 1141 | 1142 | 1143 | 1144 | 1145 | 1146 | 1147 | 1148 | 1149 | 1150 | 1151 | 1152 | 1153 | 1154 | 1155 | 1156 | 1157 | 1158 | 1159 | 1160 | 1161 | 1162 | 1163 | 1164 | 1165 | 1166 | 1167 | 1168 | 1169 | 1170 | 1171 | 1172 | 1173 | 1174 | 1175 | 1176 | 1177 | 1178 | 1179 | 1180 | 1181 | 1182 | 1183 | 1184 | 1185 | 1186 | 1187 | 1188 | 1189 | 1190 | 1191 | 1192 | 1193 | 1194 | 1195 | 1196 | 1197 | 1198 | 1199 | 1200 | 1201 | 1202 | 1203 | 1204 | 1205 | 1206 | 1207 | 1208 | 1209 | 1210 | 1211 | 1212 | 1213 | 1214 | 1215 | 1216 | 1217 | 1218 | 1219 | 1220 | 1221 | 1222 | 1223 | 1224 | 1225 | 1226 | 1227 | 1228 | 1229 | 1230 | 1231 | 1232 | 1233 | 1234 | 1235 | 1236 | 1237 | 1238 | 1239 | 1240 | 1241 | 1242 | 1243 | 1244 | 1245 | 1246 | 1247 | 1248 | 1249 | 1250 | 1251 | 1252 | 1253 | 1254 | 1255 | 1256 | 1257 | 1258 | 1259 | 1260 | 1261 | 1262 | 1263 | 1264 | 1265 | 1266 | -------------------------------------------------------------------------------- /cwa_qr/rollover.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, date, time, timedelta 2 | 3 | 4 | def rollover_date(dt: datetime, rollover: time) -> date: 5 | given_time = dt.time() 6 | if given_time < rollover: 7 | return dt.date() - timedelta(days=1) 8 | else: 9 | return dt.date() 10 | -------------------------------------------------------------------------------- /cwa_qr/seed.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | 4 | def construct_seed(seed) -> bytes: 5 | if type(seed) == bytes and len(seed) == 16: 6 | return seed 7 | 8 | if seed is None: 9 | seed = b'' 10 | 11 | if type(seed) not in [int, float, str, bytes]: 12 | seed = str(seed) 13 | 14 | r = random.Random() 15 | r.seed(seed) 16 | return bytes([r.randrange(0, 256) for _ in range(0, 16)]) 17 | -------------------------------------------------------------------------------- /cwa_qr/test_cwa.py: -------------------------------------------------------------------------------- 1 | import io 2 | from datetime import datetime, timedelta, timezone 3 | 4 | import qrcode 5 | 6 | from . import cwa 7 | 8 | full_event_description = cwa.CwaEventDescription() 9 | full_event_description.location_description = 'Zuhause' 10 | full_event_description.location_address = 'Gau-Odernheim' 11 | full_event_description.start_date_time = datetime.now(timezone.utc) 12 | full_event_description.end_date_time = datetime.now(timezone.utc) + timedelta(days=2) 13 | full_event_description.location_type = cwa.CwaLocation.permanent_workplace 14 | full_event_description.default_check_in_length_in_minutes = 4 * 60 15 | 16 | 17 | def test_generate_with_minimal_parameters(): 18 | minimal_event_description = cwa.CwaEventDescription() 19 | minimal_event_description.location_description = 'Zuhause' 20 | minimal_event_description.location_address = 'Gau-Odernheim' 21 | url = cwa.generate_url(minimal_event_description) 22 | assert url != '' 23 | 24 | 25 | def test_generate_with_all_parameters(): 26 | url = cwa.generate_url(full_event_description) 27 | assert url != '' 28 | 29 | 30 | def test_generate_without_seed_creates_same_results(): 31 | url_a = cwa.generate_url(full_event_description) 32 | url_b = cwa.generate_url(full_event_description) 33 | assert url_a == url_b 34 | 35 | 36 | def test_generate_with_same_seed_creates_same_result(): 37 | full_event_description.seed = 'a' 38 | url_a = cwa.generate_url(full_event_description) 39 | url_b = cwa.generate_url(full_event_description) 40 | assert url_a == url_b 41 | 42 | 43 | def test_generate_with_different_seeds_creates_different_results(): 44 | full_event_description.seed = 'a' 45 | url_a = cwa.generate_url(full_event_description) 46 | 47 | full_event_description.seed = 'b' 48 | url_b = cwa.generate_url(full_event_description) 49 | 50 | assert url_a != url_b 51 | 52 | 53 | def test_generate_url(): 54 | url = cwa.generate_url(full_event_description) 55 | assert url is not None 56 | assert isinstance(url, str) 57 | 58 | 59 | def test_generate_payload_object(): 60 | payload = cwa.generate_payload(full_event_description) 61 | assert payload is not None 62 | assert isinstance(payload, cwa.lowlevel.QRCodePayload) 63 | 64 | 65 | def test_generate_qr_code(): 66 | qr = cwa.generate_qr_code(full_event_description) 67 | assert qr is not None 68 | assert isinstance(qr, qrcode.QRCode) 69 | 70 | 71 | def test_generate_qr_code_png(): 72 | qr = cwa.generate_qr_code(full_event_description) 73 | img = qr.make_image(fill_color="black", back_color="white") 74 | 75 | img_bytes = io.BytesIO() 76 | img.save(img_bytes) 77 | 78 | assert img_bytes.getvalue().startswith(b'\x89PNG\r\n\x1a\n') 79 | 80 | 81 | def test_generate_qr_code_svg(): 82 | import qrcode.image.svg 83 | 84 | qr = cwa.generate_qr_code(full_event_description) 85 | svg = qr.make_image(image_factory=qrcode.image.svg.SvgPathFillImage) 86 | 87 | svg_bytes = io.BytesIO() 88 | svg.save(svg_bytes) 89 | 90 | assert svg_bytes.getvalue().startswith(b'=42", 4 | "wheel" 5 | ] 6 | build-backend = "setuptools.build_meta" 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from setuptools import find_packages, setup 3 | 4 | with open("README.md", "r", encoding="utf-8") as fh: 5 | long_description = fh.read() 6 | 7 | setup( 8 | name='cwa_qr', 9 | version='1.2.2', 10 | description='Python Implementation of the CoronaWarnApp (CWA) Event Registration', 11 | long_description=long_description, 12 | long_description_content_type="text/markdown", 13 | author='Peter Körner', 14 | author_email='peter@mazdermind.de', 15 | url='https://github.com/MaZderMind/cwa-qr', 16 | project_urls={ 17 | "Bug Tracker": "https://github.com/MaZderMind/cwa-qr/issues", 18 | }, 19 | classifiers=[ 20 | "Programming Language :: Python :: 3", 21 | "License :: OSI Approved :: MIT License", 22 | "Operating System :: OS Independent", 23 | "Development Status :: 5 - Production/Stable", 24 | ], 25 | packages=find_packages(), 26 | install_requires=[ 27 | "Pillow", 28 | "protobuf", 29 | "qrcode", 30 | "six>=1.15.0", 31 | "svgutils", 32 | ], 33 | zip_safe=True, 34 | ) 35 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -ex 3 | 4 | pytest 5 | -------------------------------------------------------------------------------- /upload_dist.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -ex 3 | 4 | ./clean.sh 5 | ./build_dist.sh 6 | ./env/bin/pip install twine 7 | ./env/bin/python -m twine upload dist/* 8 | --------------------------------------------------------------------------------