From 52023cde092923f2ac0b6898c03305227b4e5b08 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Fri, 21 Jul 2023 13:45:42 +0200 Subject: [PATCH] Reusable Media: Mifare Ultralight AES support (#3335) --- doc/api/deviceauth.rst | 28 ++- doc/api/resources/reusablemedia.rst | 3 +- src/pretix/api/auth/devicesecurity.py | 1 + src/pretix/api/serializers/event.py | 10 + src/pretix/api/serializers/organizer.py | 3 + src/pretix/api/views/device.py | 73 +++++++- src/pretix/api/views/media.py | 6 + src/pretix/base/media.py | 37 ++++ .../base/migrations/0244_mediumkeyset.py | 35 ++++ src/pretix/base/models/devices.py | 4 + src/pretix/base/models/media.py | 22 +++ src/pretix/base/services/media.py | 72 ++++++++ src/pretix/base/settings.py | 46 ++++- src/pretix/control/forms/organizer.py | 4 + src/pretix/control/logdisplay.py | 1 + .../pretixcontrol/organizers/edit.html | 26 +++ src/tests/api/test_deviceauth.py | 171 +++++++++++++++++- src/tests/api/test_reusable_media.py | 24 ++- 18 files changed, 554 insertions(+), 12 deletions(-) create mode 100644 src/pretix/base/migrations/0244_mediumkeyset.py create mode 100644 src/pretix/base/services/media.py diff --git a/doc/api/deviceauth.rst b/doc/api/deviceauth.rst index 0e5433daea..add0ca3d16 100644 --- a/doc/api/deviceauth.rst +++ b/doc/api/deviceauth.rst @@ -35,9 +35,13 @@ as well as the type of underlying hardware. Example: "os_name": "Android", "os_version": "2.3.6", "software_brand": "pretixdroid", - "software_version": "4.0.0" + "software_version": "4.0.0", + "rsa_pubkey": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqh…nswIDAQAB\n-----END PUBLIC KEY-----\n" } +The ``rsa_pubkey`` is optional any only required for certain fatures such as working with reusable +media and NFC cryptography. + Every initialization token can only be used once. On success, you will receive a response containing information on your device as well as your API token: @@ -137,9 +141,29 @@ The response will look like this: "id": 3, "name": "South entrance" } - } + }, + "server": { + "version": { + "pretix": "3.6.0.dev0", + "pretix_numeric": 30060001000 + } + }, + "medium_key_sets": [ + { + "public_id": 3456349, + "organizer": "foo", + "active": true, + "media_type": "nfc_mf0aes", + "uid_key": "base64-encoded-encrypted-key", + "diversification_key": "base64-encoded-encrypted-key", + } + ] } +``"medium_key_sets`` will always be empty if you did not set an ``rsa_pubkey``. +The individual keys in the key sets are encrypted with the device's ``rsa_pubkey`` +using ``RSA/ECB/PKCS1Padding``. + Creating a new API key ---------------------- diff --git a/doc/api/resources/reusablemedia.rst b/doc/api/resources/reusablemedia.rst index 89ab80529e..101b11b4b1 100644 --- a/doc/api/resources/reusablemedia.rst +++ b/doc/api/resources/reusablemedia.rst @@ -18,7 +18,7 @@ The reusable medium resource contains the following public fields: Field Type Description ===================================== ========================== ======================================================= id integer Internal ID of the medium -type string Type of medium, e.g. ``"barcode"`` or ``"nfc_uid"``. +type string Type of medium, e.g. ``"barcode"``, ``"nfc_uid"`` or ``"nfc_mf0aes"``. organizer string Organizer slug of the organizer who "owns" this medium. identifier string Unique identifier of the medium. The format depends on the ``type``. active boolean Whether this medium may be used. @@ -37,6 +37,7 @@ Existing media types are: - ``barcode`` - ``nfc_uid`` +- ``nfc_mf0aes`` Endpoints --------- diff --git a/src/pretix/api/auth/devicesecurity.py b/src/pretix/api/auth/devicesecurity.py index 8fa2319a3d..f01fa0f1d9 100644 --- a/src/pretix/api/auth/devicesecurity.py +++ b/src/pretix/api/auth/devicesecurity.py @@ -223,6 +223,7 @@ class PretixPosSecurityProfile(AllowListSecurityProfile): ('POST', 'api-v1:checkinrpc.redeem'), ('GET', 'api-v1:checkinrpc.search'), ('POST', 'api-v1:reusablemedium-lookup'), + ('POST', 'api-v1:reusablemedium-list'), ) diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py index 97c1ee082c..6bfed06ad1 100644 --- a/src/pretix/api/serializers/event.py +++ b/src/pretix/api/serializers/event.py @@ -817,6 +817,10 @@ class EventSettingsSerializer(SettingsSerializer): 'reusable_media_type_nfc_uid', 'reusable_media_type_nfc_uid_autocreate_giftcard', 'reusable_media_type_nfc_uid_autocreate_giftcard_currency', + 'reusable_media_type_nfc_mf0aes', + 'reusable_media_type_nfc_mf0aes_autocreate_giftcard', + 'reusable_media_type_nfc_mf0aes_autocreate_giftcard_currency', + 'reusable_media_type_nfc_mf0aes_random_uid', ] readonly_fields = [ # These are read-only since they are currently only settable on organizers, not events @@ -826,6 +830,10 @@ class EventSettingsSerializer(SettingsSerializer): 'reusable_media_type_nfc_uid', 'reusable_media_type_nfc_uid_autocreate_giftcard', 'reusable_media_type_nfc_uid_autocreate_giftcard_currency', + 'reusable_media_type_nfc_mf0aes', + 'reusable_media_type_nfc_mf0aes_autocreate_giftcard', + 'reusable_media_type_nfc_mf0aes_autocreate_giftcard_currency', + 'reusable_media_type_nfc_mf0aes_random_uid', ] def __init__(self, *args, **kwargs): @@ -894,6 +902,8 @@ class DeviceEventSettingsSerializer(EventSettingsSerializer): 'name_scheme', 'reusable_media_type_barcode', 'reusable_media_type_nfc_uid', + 'reusable_media_type_nfc_mf0aes', + 'reusable_media_type_nfc_mf0aes_random_uid', 'system_question_order', ] diff --git a/src/pretix/api/serializers/organizer.py b/src/pretix/api/serializers/organizer.py index 325c468d52..e3cd8c0753 100644 --- a/src/pretix/api/serializers/organizer.py +++ b/src/pretix/api/serializers/organizer.py @@ -392,6 +392,9 @@ class OrganizerSettingsSerializer(SettingsSerializer): 'reusable_media_type_nfc_uid', 'reusable_media_type_nfc_uid_autocreate_giftcard', 'reusable_media_type_nfc_uid_autocreate_giftcard_currency', + 'reusable_media_type_nfc_mf0aes', + 'reusable_media_type_nfc_mf0aes_autocreate_giftcard', + 'reusable_media_type_nfc_mf0aes_autocreate_giftcard_currency', ] def __init__(self, *args, **kwargs): diff --git a/src/pretix/api/views/device.py b/src/pretix/api/views/device.py index 4fee965d32..d14c61eb89 100644 --- a/src/pretix/api/views/device.py +++ b/src/pretix/api/views/device.py @@ -19,8 +19,12 @@ # You should have received a copy of the GNU Affero General Public License along with this program. If not, see # . # +import base64 import logging +from cryptography.hazmat.backends.openssl.backend import Backend +from cryptography.hazmat.primitives.asymmetric import padding +from cryptography.hazmat.primitives.serialization import load_pem_public_key from django.db.models import Exists, OuterRef, Q from django.db.models.functions import Coalesce from django.utils.timezone import now @@ -34,6 +38,8 @@ from pretix.api.auth.device import DeviceTokenAuthentication from pretix.api.views.version import numeric_version from pretix.base.models import CheckinList, Device, SubEvent from pretix.base.models.devices import Gate, generate_api_token +from pretix.base.models.media import MediumKeySet +from pretix.base.services.media import get_keysets_for_organizer logger = logging.getLogger(__name__) @@ -47,6 +53,17 @@ class InitializationRequestSerializer(serializers.Serializer): software_brand = serializers.CharField(max_length=190) software_version = serializers.CharField(max_length=190) info = serializers.JSONField(required=False, allow_null=True) + rsa_pubkey = serializers.CharField(required=False, allow_null=True) + + def validate(self, attrs): + if attrs.get('rsa_pubkey'): + try: + load_pem_public_key( + attrs['rsa_pubkey'].encode(), Backend() + ) + except: + raise ValidationError({'rsa_pubkey': ['Not a valid public key.']}) + return attrs class UpdateRequestSerializer(serializers.Serializer): @@ -57,6 +74,47 @@ class UpdateRequestSerializer(serializers.Serializer): software_brand = serializers.CharField(max_length=190) software_version = serializers.CharField(max_length=190) info = serializers.JSONField(required=False, allow_null=True) + rsa_pubkey = serializers.CharField(required=False, allow_null=True) + + def validate(self, attrs): + if attrs.get('rsa_pubkey'): + try: + load_pem_public_key( + attrs['rsa_pubkey'].encode(), Backend() + ) + except: + raise ValidationError({'rsa_pubkey': ['Not a valid public key.']}) + return attrs + + +class RSAEncryptedField(serializers.Field): + def to_representation(self, value): + public_key = load_pem_public_key( + self.context['device'].rsa_pubkey.encode(), Backend() + ) + cipher_text = public_key.encrypt( + # RSA/ECB/PKCS1Padding + value, + padding.PKCS1v15() + ) + return base64.b64encode(cipher_text).decode() + + +class MediumKeySetSerializer(serializers.ModelSerializer): + uid_key = RSAEncryptedField(read_only=True) + diversification_key = RSAEncryptedField(read_only=True) + organizer = serializers.SlugRelatedField(slug_field='slug', read_only=True) + + class Meta: + model = MediumKeySet + fields = [ + 'public_id', + 'organizer', + 'active', + 'media_type', + 'uid_key', + 'diversification_key', + ] class GateSerializer(serializers.ModelSerializer): @@ -108,6 +166,8 @@ class InitializeView(APIView): device.software_brand = serializer.validated_data.get('software_brand') device.software_version = serializer.validated_data.get('software_version') device.info = serializer.validated_data.get('info') + print(serializer.validated_data, request.data) + device.rsa_pubkey = serializer.validated_data.get('rsa_pubkey') device.api_token = generate_api_token() device.save() @@ -130,6 +190,11 @@ class UpdateView(APIView): device.os_version = serializer.validated_data.get('os_version') device.software_brand = serializer.validated_data.get('software_brand') device.software_version = serializer.validated_data.get('software_version') + if serializer.validated_data.get('rsa_pubkey') and serializer.validated_data.get('rsa_pubkey') != device.rsa_pubkey: + if device.rsa_pubkey: + raise ValidationError({'rsa_pubkey': ['You cannot change the rsa_pubkey of the device once it is set.']}) + else: + device.rsa_pubkey = serializer.validated_data.get('rsa_pubkey') device.info = serializer.validated_data.get('info') device.save() device.log_action('pretix.device.updated', data=serializer.validated_data, auth=device) @@ -177,8 +242,12 @@ class InfoView(APIView): 'pretix': __version__, 'pretix_numeric': numeric_version(__version__), } - } - + }, + 'medium_key_sets': MediumKeySetSerializer( + get_keysets_for_organizer(device.organizer), + many=True, + context={'device': request.auth} + ).data if device.rsa_pubkey else [] }) diff --git a/src/pretix/api/views/media.py b/src/pretix/api/views/media.py index 7624afd839..b6335033c7 100644 --- a/src/pretix/api/views/media.py +++ b/src/pretix/api/views/media.py @@ -104,6 +104,12 @@ class ReusableMediaViewSet(viewsets.ModelViewSet): auth=self.request.auth, data=merge_dicts(self.request.data, {'id': inst.pk}) ) + mt = MEDIA_TYPES.get(serializer.validated_data["type"]) + if mt: + m = mt.handle_new(self.request.organizer, inst, self.request.user, self.request.auth) + if m: + s = self.get_serializer(m) + return Response({"result": s.data}) @transaction.atomic() def perform_update(self, serializer): diff --git a/src/pretix/base/media.py b/src/pretix/base/media.py index d96a32f651..6e3b635fbb 100644 --- a/src/pretix/base/media.py +++ b/src/pretix/base/media.py @@ -49,6 +49,9 @@ class BaseMediaType: def handle_unknown(self, organizer, identifier, user, auth): pass + def handle_new(self, organizer, medium, user, auth): + pass + def __str__(self): return str(self.verbose_name) @@ -108,9 +111,43 @@ class NfcUidMediaType(BaseMediaType): return m +class NfcMf0aesMediaType(BaseMediaType): + identifier = 'nfc_mf0aes' + verbose_name = 'NFC Mifare Ultralight AES' + medium_created_by_server = False + supports_giftcard = True + supports_orderposition = False + + def handle_new(self, organizer, medium, user, auth): + from pretix.base.models import GiftCard + + if organizer.settings.get(f'reusable_media_type_{self.identifier}_autocreate_giftcard', as_type=bool): + with transaction.atomic(): + gc = GiftCard.objects.create( + issuer=organizer, + expires=organizer.default_gift_card_expiry, + currency=organizer.settings.get(f'reusable_media_type_{self.identifier}_autocreate_giftcard_currency'), + ) + medium.linked_giftcard = gc + medium.save() + medium.log_action( + 'pretix.reusable_medium.linked_giftcard.changed', + user=user, auth=auth, + data={ + 'linked_giftcard': gc.pk + } + ) + gc.log_action( + 'pretix.giftcards.created', + user=user, auth=auth, + ) + return medium + + MEDIA_TYPES = { m.identifier: m for m in [ BarcodePlainMediaType(), NfcUidMediaType(), + NfcMf0aesMediaType(), ] } diff --git a/src/pretix/base/migrations/0244_mediumkeyset.py b/src/pretix/base/migrations/0244_mediumkeyset.py new file mode 100644 index 0000000000..87bc891754 --- /dev/null +++ b/src/pretix/base/migrations/0244_mediumkeyset.py @@ -0,0 +1,35 @@ +# Generated by Django 3.2.18 on 2023-05-17 11:32 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0243_device_os_name_and_os_version'), + ] + + operations = [ + migrations.AddField( + model_name='device', + name='rsa_pubkey', + field=models.TextField(null=True), + ), + migrations.CreateModel( + name='MediumKeySet', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('public_id', models.BigIntegerField(unique=True)), + ('media_type', models.CharField(max_length=100)), + ('active', models.BooleanField(default=True)), + ('uid_key', models.BinaryField()), + ('diversification_key', models.BinaryField()), + ('organizer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='medium_key_sets', to='pretixbase.organizer')), + ], + ), + migrations.AddConstraint( + model_name='mediumkeyset', + constraint=models.UniqueConstraint(condition=models.Q(('active', True)), fields=('organizer', 'media_type'), name='keyset_unique_active'), + ), + ] diff --git a/src/pretix/base/models/devices.py b/src/pretix/base/models/devices.py index f72ca0a38a..a45d9c1fce 100644 --- a/src/pretix/base/models/devices.py +++ b/src/pretix/base/models/devices.py @@ -166,6 +166,10 @@ class Device(LoggedModel): null=True, blank=False ) + rsa_pubkey = models.TextField( + null=True, + blank=True, + ) info = models.JSONField( null=True, blank=True, ) diff --git a/src/pretix/base/models/media.py b/src/pretix/base/models/media.py index 59eb386d88..b5d2a3df23 100644 --- a/src/pretix/base/models/media.py +++ b/src/pretix/base/models/media.py @@ -123,3 +123,25 @@ class ReusableMedium(LoggedModel): unique_together = (("identifier", "type", "organizer"),) index_together = (("identifier", "type", "organizer"), ("updated", "id")) ordering = "identifier", "type", "organizer" + + +class MediumKeySet(models.Model): + organizer = models.ForeignKey('Organizer', on_delete=models.CASCADE, related_name='medium_key_sets') + public_id = models.BigIntegerField( + unique=True, + ) + media_type = models.CharField(max_length=100) + active = models.BooleanField(default=True) + uid_key = models.BinaryField() + diversification_key = models.BinaryField() + + objects = ScopedManager(organizer='organizer') + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["organizer", "media_type"], + condition=Q(active=True), + name="keyset_unique_active", + ), + ] diff --git a/src/pretix/base/services/media.py b/src/pretix/base/services/media.py new file mode 100644 index 0000000000..787b009fcf --- /dev/null +++ b/src/pretix/base/services/media.py @@ -0,0 +1,72 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# +import secrets + +from django.db import IntegrityError +from django.db.models import Q +from django_scopes import scopes_disabled + +from pretix.base.models import GiftCardAcceptance +from pretix.base.models.media import MediumKeySet + + +def create_nfc_mf0aes_keyset(organizer): + for i in range(20): + public_id = secrets.randbelow(2 ** 32) + uid_key = secrets.token_bytes(16) + diversification_key = secrets.token_bytes(16) + try: + return MediumKeySet.objects.create( + organizer=organizer, + media_type="nfc_mf0aes", + public_id=public_id, + diversification_key=diversification_key, + uid_key=uid_key, + active=True, + ) + except IntegrityError: # either race condition with another thread or duplicate public ID + try: + return MediumKeySet.objects.get( + organizer=organizer, + media_type="nfc_mf0aes", + active=True, + ) + except MediumKeySet.DoesNotExist: + continue # duplicate public ID, let's try again + + +@scopes_disabled() +def get_keysets_for_organizer(organizer): + sets = list(MediumKeySet.objects.filter( + Q(organizer=organizer) | Q(organizer__in=GiftCardAcceptance.objects.filter( + acceptor=organizer, + active=True, + reusable_media=True, + ).values_list("issuer_id", flat=True)) + )) + if organizer.settings.reusable_media_type_nfc_mf0aes and not any( + ks.organizer == organizer and ks.media_type == "nfc_mf0aes" for ks in sets + ): + new_set = create_nfc_mf0aes_keyset(organizer) + if new_set: + sets.append(new_set) + return sets diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index 46fab3bb97..d4efb2fcd7 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -259,6 +259,46 @@ DEFAULTS = { label=_("Gift card currency"), ) }, + 'reusable_media_type_nfc_mf0aes': { + 'default': 'False', + 'type': bool, + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, + 'form_kwargs': dict( + label=_("Active"), + ) + }, + 'reusable_media_type_nfc_mf0aes_autocreate_giftcard': { + 'default': 'False', + 'type': bool, + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, + 'form_kwargs': dict( + label=_("Automatically create a new gift card if a new chip is encoded"), + ) + }, + 'reusable_media_type_nfc_mf0aes_autocreate_giftcard_currency': { + 'default': 'EUR', + 'type': str, + 'form_class': forms.ChoiceField, + 'serializer_class': serializers.ChoiceField, + 'serializer_kwargs': dict( + choices=[(c.alpha_3, c.alpha_3 + " - " + c.name) for c in settings.CURRENCIES], + ), + 'form_kwargs': dict( + choices=[(c.alpha_3, c.alpha_3 + " - " + c.name) for c in settings.CURRENCIES], + label=_("Gift card currency"), + ) + }, + 'reusable_media_type_nfc_mf0aes_random_uid': { + 'default': 'False', + 'type': bool, + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, + 'form_kwargs': dict( + label=_("Use UID protection feature of NFC chip"), + ) + }, 'max_items_per_order': { 'default': '10', 'type': int, @@ -3657,14 +3697,10 @@ def validate_organizer_settings(organizer, settings_dict): # This is not doing anything for the time being. # But earlier we called validate_event_settings for the organizer, too - and that didn't do anything for # organizer-settings either. - # - # N.B.: When actually fleshing out this stub, adding it to the OrganizerUpdateForm should be considered. - """ - if settings_dict.get('reusable_media_type_ntag_pretix1') and settings_dict.get('reusable_media_type_nfc_uid'): + if settings_dict.get('reusable_media_type_nfc_mf0aes') and settings_dict.get('reusable_media_type_nfc_uid'): raise ValidationError({ 'reusable_media_type_nfc_uid': _('This needs to be disabled if other NFC-based types are active.') }) - """ def global_settings_object(holder): diff --git a/src/pretix/control/forms/organizer.py b/src/pretix/control/forms/organizer.py index 15a0e8e11b..efee29375e 100644 --- a/src/pretix/control/forms/organizer.py +++ b/src/pretix/control/forms/organizer.py @@ -412,6 +412,10 @@ class OrganizerSettingsForm(SettingsForm): 'reusable_media_type_nfc_uid', 'reusable_media_type_nfc_uid_autocreate_giftcard', 'reusable_media_type_nfc_uid_autocreate_giftcard_currency', + 'reusable_media_type_nfc_mf0aes', + 'reusable_media_type_nfc_mf0aes_autocreate_giftcard', + 'reusable_media_type_nfc_mf0aes_autocreate_giftcard_currency', + 'reusable_media_type_nfc_mf0aes_random_uid', ] organizer_logo_image = ExtFileField( diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index ee904891c3..ec6a4600e2 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -368,6 +368,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs): 'pretix.reusable_medium.created.auto': _('The reusable medium has been created automatically.'), 'pretix.reusable_medium.changed': _('The reusable medium has been changed.'), 'pretix.reusable_medium.linked_orderposition.changed': _('The medium has been connected to a new ticket.'), + 'pretix.reusable_medium.linked_giftcard.changed': _('The medium has been connected to a new gift card.'), 'pretix.email.error': _('Sending of an email has failed.'), 'pretix.event.comment': _('The event\'s internal comment has been updated.'), 'pretix.event.canceled': _('The event has been canceled.'), diff --git a/src/pretix/control/templates/pretixcontrol/organizers/edit.html b/src/pretix/control/templates/pretixcontrol/organizers/edit.html index 52628f572a..31ac095bb0 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/edit.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/edit.html @@ -262,6 +262,32 @@ + +
+
+

NFC Mifare Ultralight AES

+
+
+

+ {% blocktrans trimmed %} + This medium type works only with NFC chips of the type Mifare Ultralight AES + made by NXP. This provides a higher level of security than other approaches, but + requires all chips to be encoded prior to use. + {% endblocktrans %} + {% blocktrans trimmed %} + NFC media can currently only be connected to gift cards. + {% endblocktrans %} +

+ {% bootstrap_field sform.reusable_media_type_nfc_mf0aes layout="control" %} +
+ {% bootstrap_field sform.reusable_media_type_nfc_mf0aes_autocreate_giftcard layout="control" %} +
+ {% bootstrap_field sform.reusable_media_type_nfc_mf0aes_autocreate_giftcard_currency layout="control" %} +
+ {% bootstrap_field sform.reusable_media_type_nfc_mf0aes_random_uid layout="control" %} +
+
+
diff --git a/src/tests/api/test_deviceauth.py b/src/tests/api/test_deviceauth.py index 04e88d7d24..bed8fb0e35 100644 --- a/src/tests/api/test_deviceauth.py +++ b/src/tests/api/test_deviceauth.py @@ -19,7 +19,12 @@ # You should have received a copy of the GNU Affero General Public License along with this program. If not, see # . # +import base64 + import pytest +from cryptography.hazmat.primitives.asymmetric import padding +from cryptography.hazmat.primitives.serialization import load_pem_private_key +from django_scopes import scopes_disabled from pretix.base.models import Device @@ -96,7 +101,12 @@ def test_initialize_valid_token(client, new_device: Device): 'software_brand': 'pretixdroid', 'os_name': 'Android', 'os_version': '2.3.3', - 'software_version': '4.0.0' + 'software_version': '4.0.0', + 'rsa_pubkey': '-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAkvaNeGmKagUI8jux+beh\nui' + 'MoH6NUvbhRaY5xg7IMpG2BoauIIRhzwoqNaVHGYPX+7PpYwNxLfvYk2e83clox\nvS4+WeJND5b+Ja+Ua+AOr4XAvTNWK/ojZ' + 'DcP3fAjc6pPEVvWPQmq2qLcBjBtkRSv\nH83kAs/4bZIp+pRmMAVusp2c2BOLIgXXzxKg8oUNeRE8LFzHi45EMVTLOT59L5DK' + '\nRD1V11/rSpBBl08E5eYeHEAO8p+WfiS5YVWrx/fvbNnsrPh8GbOgHWhdXONddQTL\nCudo/VVOdLM9Oe92xJBla+0QeeKgn' + 'ElBSD55prRNezQjnxGToTist13mOjS4fQ+I\nswIDAQAB\n-----END PUBLIC KEY-----', }) assert resp.status_code == 200 assert resp.data['organizer'] == 'dummy' @@ -108,6 +118,12 @@ def test_initialize_valid_token(client, new_device: Device): assert new_device.api_token assert new_device.initialized assert new_device.os_version == "2.3.3" + assert new_device.rsa_pubkey == '-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAkvaNeG' \ + 'mKagUI8jux+beh\nuiMoH6NUvbhRaY5xg7IMpG2BoauIIRhzwoqNaVHGYPX+7PpYwNxLfvYk2e83cl' \ + 'ox\nvS4+WeJND5b+Ja+Ua+AOr4XAvTNWK/ojZDcP3fAjc6pPEVvWPQmq2qLcBjBtkRSv\nH83kAs/4' \ + 'bZIp+pRmMAVusp2c2BOLIgXXzxKg8oUNeRE8LFzHi45EMVTLOT59L5DK\nRD1V11/rSpBBl08E5eYe' \ + 'HEAO8p+WfiS5YVWrx/fvbNnsrPh8GbOgHWhdXONddQTL\nCudo/VVOdLM9Oe92xJBla+0QeeKgnElB' \ + 'SD55prRNezQjnxGToTist13mOjS4fQ+I\nswIDAQAB\n-----END PUBLIC KEY-----' @pytest.mark.django_db @@ -152,12 +168,106 @@ def test_update_valid_fields(device_client, device: Device): 'info': { 'foo': 'bar' }, + 'rsa_pubkey': '-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAkvaNeGmKagUI8jux+beh\n' + 'uiMoH6NUvbhRaY5xg7IMpG2BoauIIRhzwoqNaVHGYPX+7PpYwNxLfvYk2e83clox\nvS4+WeJND5b+Ja+Ua+AOr4XAvTNW' + 'K/ojZDcP3fAjc6pPEVvWPQmq2qLcBjBtkRSv\nH83kAs/4bZIp+pRmMAVusp2c2BOLIgXXzxKg8oUNeRE8LFzHi45EMVTL' + 'OT59L5DK\nRD1V11/rSpBBl08E5eYeHEAO8p+WfiS5YVWrx/fvbNnsrPh8GbOgHWhdXONddQTL\nCudo/VVOdLM9Oe92xJ' + 'Bla+0QeeKgnElBSD55prRNezQjnxGToTist13mOjS4fQ+I\nswIDAQAB\n-----END PUBLIC KEY-----', }, format='json') assert resp.status_code == 200 device.refresh_from_db() assert device.software_version == '5.0.0' assert device.os_version == '2.3.3' assert device.info == {'foo': 'bar'} + assert device.rsa_pubkey == '-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAkvaNeGmKagU' \ + 'I8jux+beh\nuiMoH6NUvbhRaY5xg7IMpG2BoauIIRhzwoqNaVHGYPX+7PpYwNxLfvYk2e83clox\nvS4+We' \ + 'JND5b+Ja+Ua+AOr4XAvTNWK/ojZDcP3fAjc6pPEVvWPQmq2qLcBjBtkRSv\nH83kAs/4bZIp+pRmMAVusp2' \ + 'c2BOLIgXXzxKg8oUNeRE8LFzHi45EMVTLOT59L5DK\nRD1V11/rSpBBl08E5eYeHEAO8p+WfiS5YVWrx/fv' \ + 'bNnsrPh8GbOgHWhdXONddQTL\nCudo/VVOdLM9Oe92xJBla+0QeeKgnElBSD55prRNezQjnxGToTist13mO' \ + 'jS4fQ+I\nswIDAQAB\n-----END PUBLIC KEY-----' + + +@pytest.mark.django_db +def test_update_rsa_unchanged(device_client, device: Device): + device.rsa_pubkey = '-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAkvaNeGmKagUI8jux+be' \ + 'h\nuiMoH6NUvbhRaY5xg7IMpG2BoauIIRhzwoqNaVHGYPX+7PpYwNxLfvYk2e83clox\nvS4+WeJND5b+Ja+Ua+AOr4' \ + 'XAvTNWK/ojZDcP3fAjc6pPEVvWPQmq2qLcBjBtkRSv\nH83kAs/4bZIp+pRmMAVusp2c2BOLIgXXzxKg8oUNeRE8LFz' \ + 'Hi45EMVTLOT59L5DK\nRD1V11/rSpBBl08E5eYeHEAO8p+WfiS5YVWrx/fvbNnsrPh8GbOgHWhdXONddQTL\nCudo/V' \ + 'VOdLM9Oe92xJBla+0QeeKgnElBSD55prRNezQjnxGToTist13mOjS4fQ+I\nswIDAQAB\n-----END PUBLIC KEY--' \ + '---' + device.save() + resp = device_client.post('/api/v1/device/update', { + 'hardware_brand': 'Samsung', + 'hardware_model': 'Galaxy S', + 'software_brand': 'pretixdroid', + 'software_version': '5.0.0', + 'info': { + 'foo': 'bar' + }, + 'rsa_pubkey': '-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAkvaNeGmKagUI8jux+beh' + '\nuiMoH6NUvbhRaY5xg7IMpG2BoauIIRhzwoqNaVHGYPX+7PpYwNxLfvYk2e83clox\nvS4+WeJND5b+Ja+Ua+AOr4XA' + 'vTNWK/ojZDcP3fAjc6pPEVvWPQmq2qLcBjBtkRSv\nH83kAs/4bZIp+pRmMAVusp2c2BOLIgXXzxKg8oUNeRE8LFzHi4' + '5EMVTLOT59L5DK\nRD1V11/rSpBBl08E5eYeHEAO8p+WfiS5YVWrx/fvbNnsrPh8GbOgHWhdXONddQTL\nCudo/VVOdL' + 'M9Oe92xJBla+0QeeKgnElBSD55prRNezQjnxGToTist13mOjS4fQ+I\nswIDAQAB\n-----END PUBLIC KEY-----', + }, format='json') + assert resp.status_code == 200 + device.refresh_from_db() + assert device.software_version == '5.0.0' + assert device.info == {'foo': 'bar'} + assert device.rsa_pubkey == '-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAkvaNeGmKag' \ + 'UI8jux+beh\nuiMoH6NUvbhRaY5xg7IMpG2BoauIIRhzwoqNaVHGYPX+7PpYwNxLfvYk2e83clox\nvS4+' \ + 'WeJND5b+Ja+Ua+AOr4XAvTNWK/ojZDcP3fAjc6pPEVvWPQmq2qLcBjBtkRSv\nH83kAs/4bZIp+pRmMAVu' \ + 'sp2c2BOLIgXXzxKg8oUNeRE8LFzHi45EMVTLOT59L5DK\nRD1V11/rSpBBl08E5eYeHEAO8p+WfiS5YVWr' \ + 'x/fvbNnsrPh8GbOgHWhdXONddQTL\nCudo/VVOdLM9Oe92xJBla+0QeeKgnElBSD55prRNezQjnxGToTis' \ + 't13mOjS4fQ+I\nswIDAQAB\n-----END PUBLIC KEY-----' + + +@pytest.mark.django_db +def test_update_rsa_invalid(device_client, device: Device): + device.save() + resp = device_client.post('/api/v1/device/update', { + 'hardware_brand': 'Samsung', + 'hardware_model': 'Galaxy S', + 'software_brand': 'pretixdroid', + 'software_version': '5.0.0', + 'info': { + 'foo': 'bar' + }, + 'rsa_pubkey': 'notakey', + }, format='json') + assert resp.status_code == 400 + + +@pytest.mark.django_db +def test_update_rsa_changed(device_client, device: Device): + device.rsa_pubkey = '-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAkvaNeGmKagUI8jux+beh' \ + '\nuiMoH6NUvbhRaY5xg7IMpG2BoauIIRhzwoqNaVHGYPX+7PpYwNxLfvYk2e83clox\nvS4+WeJND5b+Ja+Ua+AOr4XA' \ + 'vTNWK/ojZDcP3fAjc6pPEVvWPQmq2qLcBjBtkRSv\nH83kAs/4bZIp+pRmMAVusp2c2BOLIgXXzxKg8oUNeRE8LFzHi4' \ + '5EMVTLOT59L5DK\nRD1V11/rSpBBl08E5eYeHEAO8p+WfiS5YVWrx/fvbNnsrPh8GbOgHWhdXONddQTL\nCudo/VVOdL' \ + 'M9Oe92xJBla+0QeeKgnElBSD55prRNezQjnxGToTist13mOjS4fQ+I\nswIDAQAB\n-----END PUBLIC KEY-----' + device.save() + resp = device_client.post('/api/v1/device/update', { + 'hardware_brand': 'Samsung', + 'hardware_model': 'Galaxy S', + 'software_brand': 'pretixdroid', + 'software_version': '5.0.0', + 'info': { + 'foo': 'bar' + }, + 'rsa_pubkey': '-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4ebJ9BfBqZ3Tkndrnbrc\n' + '3PPkhbws3U81f3aRMCmZyvzEi/pq1HfsC8fk2YptXDROio2mGt799SZMZBfTwknZ\nNTqkB1ZTVHwO8rz0i1yPphe1I+xN' + '/eG8CQiRYCv7nh6+Us989OTgD1sFNx8F9vX/\nAw1TRXUn7F10iPP4J3Ns2j1d4hZyp811Nfgrb0q348qv9TrK52AAQc0F' + 'UWypAI8K\ngC756SbINSjAJSBvEYqTA2ORIjkoW+xLhbpvaCp5HZ7aARCihxRGmu/H830xf7cL\nK8PEdFCKr8ZDZrRFTi' + 'bSX8RXIGWhJZBykqBtkNrJniwhq9gcWkGMVP9bQxRnCRzy\nOQIDAQAB\n-----END PUBLIC KEY-----' + }, format='json') + assert resp.status_code == 400 + device.refresh_from_db() + assert device.rsa_pubkey == '-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAkvaNeGmKagUI' \ + '8jux+beh\nuiMoH6NUvbhRaY5xg7IMpG2BoauIIRhzwoqNaVHGYPX+7PpYwNxLfvYk2e83clox\nvS4+WeJN' \ + 'D5b+Ja+Ua+AOr4XAvTNWK/ojZDcP3fAjc6pPEVvWPQmq2qLcBjBtkRSv\nH83kAs/4bZIp+pRmMAVusp2c2B' \ + 'OLIgXXzxKg8oUNeRE8LFzHi45EMVTLOT59L5DK\nRD1V11/rSpBBl08E5eYeHEAO8p+WfiS5YVWrx/fvbNns' \ + 'rPh8GbOgHWhdXONddQTL\nCudo/VVOdLM9Oe92xJBla+0QeeKgnElBSD55prRNezQjnxGToTist13mOjS4fQ' \ + '+I\nswIDAQAB\n-----END PUBLIC KEY-----' @pytest.mark.django_db @@ -217,3 +327,62 @@ def test_device_info(device_client, device: Device): assert 'unique_serial' in resp.data['device'] assert 'api_token' in resp.data['device'] assert 'pretix' in resp.data['server']['version'] + + +@pytest.mark.django_db +def test_device_info_key_sets(device_client, device: Device): + device.organizer.settings.reusable_media_type_nfc_mf0aes = True + resp = device_client.get('/api/v1/device/info') + assert resp.status_code == 200 + assert resp.data['medium_key_sets'] == [] + + device.rsa_pubkey = '-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4ebJ9BfBqZ3Tkndrnbr' \ + 'c\n3PPkhbws3U81f3aRMCmZyvzEi/pq1HfsC8fk2YptXDROio2mGt799SZMZBfTwknZ\nNTqkB1ZTVHwO8rz0i1yPph' \ + 'e1I+xN/eG8CQiRYCv7nh6+Us989OTgD1sFNx8F9vX/\nAw1TRXUn7F10iPP4J3Ns2j1d4hZyp811Nfgrb0q348qv9Tr' \ + 'K52AAQc0FUWypAI8K\ngC756SbINSjAJSBvEYqTA2ORIjkoW+xLhbpvaCp5HZ7aARCihxRGmu/H830xf7cL\nK8PEdF' \ + 'CKr8ZDZrRFTibSX8RXIGWhJZBykqBtkNrJniwhq9gcWkGMVP9bQxRnCRzy\nOQIDAQAB\n-----END PUBLIC KEY--' \ + '---' + device.save() + + resp = device_client.get('/api/v1/device/info') + assert resp.status_code == 200 + assert len(resp.data['medium_key_sets']) == 1 + ks = resp.data['medium_key_sets'][0] + with scopes_disabled(): + keyset = device.organizer.medium_key_sets.get(media_type="nfc_mf0aes") + + assert ks['organizer'] == device.organizer.slug + assert ks['public_id'] == keyset.public_id + assert ks['active'] + assert ks['media_type'] == 'nfc_mf0aes' + assert len(keyset.diversification_key) == 16 + assert len(keyset.uid_key) == 16 + + private_key = load_pem_private_key( + b'-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA4ebJ9BfBqZ3Tkndrnbrc3PPkhbws3U81f3aRMCmZyvzEi/pq\n1HfsC8f' + b'k2YptXDROio2mGt799SZMZBfTwknZNTqkB1ZTVHwO8rz0i1yPphe1I+xN\n/eG8CQiRYCv7nh6+Us989OTgD1sFNx8F9vX/Aw1TRXUn7F1' + b'0iPP4J3Ns2j1d4hZy\np811Nfgrb0q348qv9TrK52AAQc0FUWypAI8KgC756SbINSjAJSBvEYqTA2ORIjko\nW+xLhbpvaCp5HZ7aARCih' + b'xRGmu/H830xf7cLK8PEdFCKr8ZDZrRFTibSX8RXIGWh\nJZBykqBtkNrJniwhq9gcWkGMVP9bQxRnCRzyOQIDAQABAoIBAEBLO8pdopBga' + b'5GB\nrJ7hSrAWOEG523kHbL4A5HS1OmDUDSqb1KDxGr0FoQQrSlHWT05O32pBcj0+L7rD\nL1FaTFhCfuHZt3DRuD1s+xrY9sd6cuMtA8u' + b'Q3kAh8KJTElOgA2I1TKa0p3KnYLYd\n/cganoBjYAJiREEZHixGZ6fuyZnZGXpkqHuWnc+9LoSSWfqUwKdHMxgwulbQdoai\nCNuKZzSpB' + b'ikPyNvDYkJVn/eT2OD9qYa+HwPZVojL8yswtTlhPGkmha+LGuBt3JIx\n8tHK6+yAgvrV08Jb9AgmJYrGAxhwK1GWMXr41cFxBs/GXSX+d' + b'vSbWqpYdUrJ8zhP\nuFn+awMCgYEA9t0WvNcm8PKNQUnUpiuJB9/0vSVITMJhYaN4G/GQ/9eg15LC5qdE\n586fX7SS4UDfwZfruElZIvu' + b'FhB38/cNoqjEGRERyFuRrmUG5t3974aQGoufCucDP\nFvJgWFlFX8FAvtMRRIu4JGC6ie9dlaReyYPuwRKaJhBEVwcN0lOP10MCgYEA6kM' + b'Y\nK1mZ3SWBNrhGsNvM3q+4QHq0u2AI5/OkQTjAxy4X6XmPeor4Xrvq5NdcMbCjTUKD\nbyA/JOopxm4eE7kLBMKEBrWivIjn5/TdBl+0J' + b'HsH1bjfqX/1DD26gFxnxC3Nm9Up\nqcsT9q6zH/lktJsLlGgHc3sNHIIvuXOsX4CgAtMCgYBjrD7K/l/Vt0k7TDEU6s0I\nJe+uEwiPHYi' + b'uII+VUMLH2esyPyp8cJsMsUt+G+2WD1iI1Osy3EKmMkHlZypH14dB\n+EtccvpRreaX2Ya/xTRilZSsX8EquOOkkzY9VcYB9IhMw/Hb6EH' + b'wRjHrEX+KtPQk\njyVuRTGCHt1I+isleeHA+wKBgD1Kvrkg4WP+GxexETXW3HxrJ18fe8gGsW3WzmQO\nMEos4i7BEmwyjhdjPWsQedu6Z' + b'o+hVngtzLeg2LtFNnNcl+hv6FFFFsYTX/HNnEK9\nqYld80fU7hgQFZJVWEWbZ77paQFbvWHic1+4h79W5iVm55m1ujVZva121nvEKxZ1' + b'\ntefnAoGBAI/vON+iYS9SqQ6G8IZyeut41fRj5nLQgnEUHdfvNn0tKxlXd2DcKPZ/\n20P6xmoNp1QRAWILn0TQ58hLIXHdXqDegdcA/' + b'W5r4GEbFg28w1lKcm6e07F9PPRz\n/90ZV8BUHZJuCUKL91lGeibs2VtOt4lDZAn3mBrza3udxZ4UwzRT\n' + b'-----END RSA PRIVATE KEY-----', + None + ) + assert bytes(keyset.uid_key) == private_key.decrypt( + base64.b64decode(ks['uid_key']), + padding.PKCS1v15() + ) + assert bytes(keyset.diversification_key) == private_key.decrypt( + base64.b64decode(ks['diversification_key']), + padding.PKCS1v15() + ) diff --git a/src/tests/api/test_reusable_media.py b/src/tests/api/test_reusable_media.py index 516abe80f6..bb27a63efc 100644 --- a/src/tests/api/test_reusable_media.py +++ b/src/tests/api/test_reusable_media.py @@ -334,7 +334,7 @@ def test_medium_lookup_not_found(token_client, organizer, organizer2, medium): @pytest.mark.django_db -def test_medium_autocreate(token_client, organizer): +def test_medium_lookup_autocreate(token_client, organizer): # Disabled resp = token_client.post( '/api/v1/organizers/{}/reusablemedia/lookup/'.format(organizer.slug), @@ -379,6 +379,28 @@ def test_medium_autocreate(token_client, organizer): assert resp.data["result"] is None +@pytest.mark.django_db +def test_medium_autocreate_giftcard(token_client, organizer): + organizer.settings.reusable_media_type_nfc_mf0aes_autocreate_giftcard = True + organizer.settings.reusable_media_type_nfc_mf0aes_autocreate_giftcard_currency = 'USD' + resp = token_client.post( + '/api/v1/organizers/{}/reusablemedia/?expand=linked_giftcard'.format(organizer.slug), + { + "type": "nfc_mf0aes", + "identifier": "AABBCCDD", + }, + format='json' + ) + assert resp.status_code == 201 + res = resp.data + with scopes_disabled(): + m = ReusableMedium.objects.get(pk=res["id"]) + assert res["identifier"] == "AABBCCDD" == m.identifier + assert res["type"] == "nfc_mf0aes" == m.type + assert res["linked_giftcard"]["value"] == "0.00" + assert res["linked_giftcard"]["currency"] == "USD" + + @pytest.mark.django_db def test_medium_lookup_cross_organizer(token_client, organizer, organizer2, org2_event, medium2, giftcard2): with scopes_disabled():