[webapps/foss-public-alert-server] /: Store VAPID public key for each subscription
Volker Krause <[email protected]>
| Newsgroups | gmane.comp.kde.cvs |
|---|---|
| Message-ID | <[email protected]> |
Git commit 11a0df0295e4d121949e476a8a92a7a689c01a42 by Volker Krause.
Committed on 10/08/2026 at 15:14.
Pushed by vkrause into branch 'master'.
Store VAPID public key for each subscription
That's the first step towards implementing VAPID key rotation. So far all
this does is storing the current key, or the one passed by the client if
it's one the server knows about. Unknown VAPID keys are rejected when
creating or updating subscriptions.
None of that is actually taken into account for submitting notifications
yet, nor is there support for multiple VAPID keys, but it at least allows
clients to adapt already.
Implementing tests for this also uncovered that updating UP tokens didn't
actually work as we didn't read them correctly from the request body.
A +18 -0 foss_public_alert_server/subscriptionHandler/migrations/0011_subscription_vapid_public_key.py
A +21 -0 foss_public_alert_server/subscriptionHandler/migrations/0012_subcription_vapid_public_key_fill.py
M +3 -1 foss_public_alert_server/subscriptionHandler/models.py
M +18 -8 foss_public_alert_server/subscriptionHandler/push_notification_services/unified_push_encrpted.py
M +39 -2 foss_public_alert_server/subscriptionHandler/tests.py
M +14 -6 foss_public_alert_server/subscriptionHandler/views.py
M +3 -0 openAPI-docu.yaml
https://invent.kde.org/webapps/foss-public-alert-server/-/commit/11a0df0295e4d121949e476a8a92a7a689c01a42
diff --git a/foss_public_alert_server/subscriptionHandler/migrations/0011_subscription_vapid_public_key.py b/foss_public_alert_server/subscriptionHandler/migrations/0011_subscription_vapid_public_key.py
new file mode 100644
index 000000000..0585fc476
--- /dev/null
+++ b/foss_public_alert_server/subscriptionHandler/migrations/0011_subscription_vapid_public_key.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.2.14 on 2026-07-17 09:27
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("subscriptionHandler", "0010_connectionflag_error_message"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="subscription",
+ name="vapid_public_key",
+ field=models.CharField(max_length=96, null=True),
+ ),
+ ]
diff --git a/foss_public_alert_server/subscriptionHandler/migrations/0012_subcription_vapid_public_key_fill.py b/foss_public_alert_server/subscriptionHandler/migrations/0012_subcription_vapid_public_key_fill.py
new file mode 100644
index 000000000..381ef55ef
--- /dev/null
+++ b/foss_public_alert_server/subscriptionHandler/migrations/0012_subcription_vapid_public_key_fill.py
@@ -0,0 +1,21 @@
+# SPDX-FileCopyrightText: Volker Krause <[email protected]>
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+from subscriptionHandler.models import Subscription
+
+from django.conf import settings
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("subscriptionHandler", "0011_subscription_vapid_public_key"),
+ ]
+
+ operations = [
+ migrations.RunSQL(
+ sql=f"UPDATE \"subscriptionHandler_subscription\" SET \"vapid_public_key\"='{settings.WEB_PUSH_CONFIG_PUBLIC_KEY}' WHERE \"push_service\"={Subscription.PushServices.UNIFIED_PUSH_ENCRYPTED}",
+ reverse_sql=migrations.RunSQL.noop
+ ),
+ ]
diff --git a/foss_public_alert_server/subscriptionHandler/models.py b/foss_public_alert_server/subscriptionHandler/models.py
index 87171abf6..9b0754a7a 100644
--- a/foss_public_alert_server/subscriptionHandler/models.py
+++ b/foss_public_alert_server/subscriptionHandler/models.py
@@ -25,9 +25,11 @@ class Subscription(models.Model):
error_counter = models.IntegerField(default=0)
error_messages = models.CharField(max_length=255, null=True)
user_agent = models.CharField(max_length=255, null=True)
+ vapid_public_key = models.CharField(max_length=96, null=True) # only used by WebPush, so we have to allow null
+
class ConnectionFlag(models.Model):
hostname = models.CharField(primary_key=True, max_length=255)
set_time_stamp = models.DateTimeField(default=datetime.now)
time_out = models.BooleanField()
- error_message = models.CharField(max_length=255, null=True)
\ No newline at end of file
+ error_message = models.CharField(max_length=255, null=True)
diff --git a/foss_public_alert_server/subscriptionHandler/push_notification_services/unified_push_encrpted.py b/foss_public_alert_server/subscriptionHandler/push_notification_services/unified_push_encrpted.py
index 7f6ba0923..e7997ce10 100644
--- a/foss_public_alert_server/subscriptionHandler/push_notification_services/unified_push_encrpted.py
+++ b/foss_public_alert_server/subscriptionHandler/push_notification_services/unified_push_encrpted.py
@@ -1,7 +1,7 @@
# SPDX-FileCopyrightText: Nucleus <[email protected]>
# SPDX-License-Identifier: AGPL-3.0-or-later
-from django.http import HttpResponseBadRequest, HttpResponse, HttpResponseNotFound
+from django.http import HttpResponseBadRequest, HttpResponse
from pywebpush import webpush, WebPushException
from django.conf import settings
import logging
@@ -36,12 +36,16 @@ def create_subscription(token, bbox, data, user_agent):
# load data from request
p256dh_key = data['p256dh_key']
auth_key = data['auth_key']
+ vapid_public_key = data.get('vapid_public_key', settings.WEB_PUSH_CONFIG_PUBLIC_KEY)
if p256dh_key == "" or auth_key == "":
return HttpResponseBadRequest('invalid or missing parameters')
+ if vapid_public_key != settings.WEB_PUSH_CONFIG_PUBLIC_KEY:
+ return HttpResponseBadRequest('unknown VAPID key')
return Subscription(token=token, bounding_box=bbox, push_service=Subscription.PushServices.UNIFIED_PUSH_ENCRYPTED,
- last_heartbeat=datetime.now(timezone.utc), p256dh_key=p256dh_key, auth_key=auth_key, user_agent=user_agent)
+ last_heartbeat=datetime.now(timezone.utc), p256dh_key=p256dh_key, auth_key=auth_key, user_agent=user_agent,
+ vapid_public_key=vapid_public_key)
def send_notification(endpoint, payload, auth_key, p256dh_key, persist_failures: bool = True) -> Response or None:
@@ -116,7 +120,7 @@ def send_notification(endpoint, payload, auth_key, p256dh_key, persist_failures:
raise PushNotificationException("failure")
-def update_subscription(token, request, subscription_id:str) -> HttpResponse:
+def update_subscription(token, data, subscription_id: str) -> HttpResponse:
"""
Update the push notification config for the given subscription.
@@ -131,13 +135,19 @@ def update_subscription(token, request, subscription_id:str) -> HttpResponse:
:param subscription_id: the id of the subscription to update
:return: HttpResponseBadRequest if there are missing parameters / invalid input, HttpResponse if the update was successful.
"""
- p256dh_key = request.GET.get("p256dh_key")
- auth_key = request.GET.get("auth_key")
- if p256dh_key == "" or auth_key == "":
+ p256dh_key = data.get("p256dh_key")
+ auth_key = data.get("auth_key")
+ vapid_public_key = data.get("vapid_public_key")
+ if p256dh_key is None or auth_key is None:
return HttpResponseBadRequest('invalid or missing parameters')
+ if vapid_public_key is not None and vapid_public_key != settings.WEB_PUSH_CONFIG_PUBLIC_KEY:
+ return HttpResponseBadRequest('unknown VAPID key')
try:
- Subscription.objects.filter(id=subscription_id).update(token=token, auth_key=auth_key, p256dh_key=p256dh_key)
+ if vapid_public_key is not None:
+ Subscription.objects.filter(id=subscription_id).update(token=token, auth_key=auth_key, p256dh_key=p256dh_key, vapid_public_key=vapid_public_key)
+ else:
+ Subscription.objects.filter(id=subscription_id).update(token=token, auth_key=auth_key, p256dh_key=p256dh_key)
return HttpResponse("Subscription and push config successfully updated")
except Exception as e:
logger.error(f"Can not update subscription: {e}")
- return HttpResponseBadRequest("invalid input")
\ No newline at end of file
+ return HttpResponseBadRequest("invalid input")
diff --git a/foss_public_alert_server/subscriptionHandler/tests.py b/foss_public_alert_server/subscriptionHandler/tests.py
index abd0dc8dc..4aee84d41 100644
--- a/foss_public_alert_server/subscriptionHandler/tests.py
+++ b/foss_public_alert_server/subscriptionHandler/tests.py
@@ -217,7 +217,7 @@ class SubscriptionHandlerTestsCase(TestCase):
for token in invalid_tokens:
data["token"] = token
data["push_service"] = "UNIFIED_PUSH"
- response = self.client.put('/subscription/', json.dumps(data), content_type="application/json", headers={"user_agent": "FPAS/1.0.0 (testing)"})
+ response = self.client.put(f'/subscription/?subscription_id={sub_id}', json.dumps(data), content_type="application/json", headers={"user_agent": "FPAS/1.0.0 (testing)"})
self.assertEqual(response.status_code, 400)
data["token"] = "https://unifiedpush.kde.org/upezVkNWZjNTM5?up=1"
@@ -231,7 +231,7 @@ class SubscriptionHandlerTestsCase(TestCase):
for token in invalid_tokens:
data["token"] = token
data["push_service"] = "UNIFIED_PUSH_ENCRYPTED"
- response = self.client.put('/subscription/', json.dumps(data), content_type="application/json", headers={"user_agent": "FPAS/1.0.0 (testing)"})
+ response = self.client.put(f'/subscription/?subscription_id={sub_id}', json.dumps(data), content_type="application/json", headers={"user_agent": "FPAS/1.0.0 (testing)"})
self.assertEqual(response.status_code, 400)
def test_expire(self):
@@ -284,3 +284,40 @@ class SubscriptionHandlerTestsCase(TestCase):
self.assertEqual(Subscription.objects.count(), prev_count + 2)
self.assertIsNotNone(Subscription.objects.get(id=subNew.id))
self.assertIsNotNone(Subscription.objects.get(id=subAboutToExpire.id))
+
+ def test_vapid_pub_key(self):
+ data = {
+ 'min_lat': 52.295,
+ 'max_lat': 52.789,
+ 'min_lon': 8.591,
+ 'max_lon': 12.063,
+ 'p256dh_key': 'BInn4ytZr6wQ960L3sQ6tfmrQzNQoEhj_I-0i2DRcL-_u0aU2vSgLuhLKyzGnFkmKDhfnZ7pwcsOEsqy-fDbzh0',
+ 'auth_key': 'ns9swjbbKTEN12VGW_tJqA',
+ 'push_service': 'UNIFIED_PUSH_ENCRYPTED',
+ 'token': 'https://unifiedpush.kde.org/upezVkNWZjNTM5?up=1',
+ }
+
+ # current key is being used as default when not set, for backward compatibility
+ response = self.client.post('/subscription/', json.dumps(data), content_type="application/json", headers={"user_agent": "FPAS/1.0.0 (testing)"})
+ self.assertEqual(response.status_code, 200)
+ sub_id = response.json()['subscription_id']
+ self.assertIsNotNone(sub_id)
+ sub = Subscription.objects.get(id=sub_id)
+ self.assertEqual(sub.vapid_public_key, 'BHJnBOSvBJ9Vl0fF44dUFxmr3l-mNSjuAGvIsFKBSWUsBu2-v2dov1UcGgE2Ry_yjJsz38F3a0A-QrAjCr3OCA4')
+
+ # update retains existing vapid key
+ response = self.client.put(f'/subscription/?subscription_id={sub_id}', json.dumps(data),
+ content_type="application/json", headers={"user_agent": "FPAS/1.0.0 (testing)"})
+ self.assertEqual(response.status_code, 200)
+ sub = Subscription.objects.get(id=sub_id)
+ self.assertEqual(sub.vapid_public_key, 'BHJnBOSvBJ9Vl0fF44dUFxmr3l-mNSjuAGvIsFKBSWUsBu2-v2dov1UcGgE2Ry_yjJsz38F3a0A-QrAjCr3OCA4')
+
+ # an explicitly specified but unknown vapid key is rejected
+ data['vapid_public_key'] = "SomeUnknownKey"
+ response = self.client.post('/subscription/', json.dumps(data), content_type="application/json", headers={"user_agent": "FPAS/1.0.0 (testing)"})
+ self.assertContains(response, "unknown VAPID key", status_code=400)
+
+ # same for updates
+ response = self.client.put(f'/subscription/?subscription_id={sub_id}', json.dumps(data),
+ content_type="application/json", headers={"user_agent": "FPAS/1.0.0 (testing)"})
+ self.assertContains(response, "unknown VAPID key", status_code=400)
diff --git a/foss_public_alert_server/subscriptionHandler/views.py b/foss_public_alert_server/subscriptionHandler/views.py
index bef0a8c51..db0084a4e 100644
--- a/foss_public_alert_server/subscriptionHandler/views.py
+++ b/foss_public_alert_server/subscriptionHandler/views.py
@@ -168,6 +168,8 @@ def add_new_subscription(request):
case "UNIFIED_PUSH_ENCRYPTED":
validateUnifiedPushToken(token)
s = unified_push_encrpted.create_subscription(token, bbox, data, user_agent)
+ if isinstance(s, HttpResponse):
+ return s
test_push = unified_push_encrpted.send_notification(s.token,
json.dumps(msg),
auth_key=s.auth_key,
@@ -254,7 +256,13 @@ def update_subscription(request):
return HttpResponseBadRequest("invalid input")
# if request contains token, handle update request
- token = request.GET.get("token")
+ data = {}
+ try:
+ data = json.loads(request.body)
+ except Exception:
+ # empty body is technically allowed if we just update the heartbeat
+ pass
+ token = data.get("token")
if token is None:
# if token is none, the request is just to update the subscription
@@ -267,14 +275,14 @@ def update_subscription(request):
match push_service:
case Subscription.PushServices.UNIFIED_PUSH:
validateUnifiedPushToken(token)
- return unified_push.update_subscription(request)
+ return unified_push.update_subscription(data)
case Subscription.PushServices.UNIFIED_PUSH_ENCRYPTED:
validateUnifiedPushToken(token)
- return unified_push_encrpted.update_subscription(token, request, subscription_id)
+ return unified_push_encrpted.update_subscription(token, data, subscription_id)
case Subscription.PushServices.APN:
- return apn.update_subscription(request)
+ return apn.update_subscription(data)
case Subscription.PushServices.FIREBASE:
- return firebase.update_subscription(request)
+ return firebase.update_subscription(data)
case _:
logger.debug("Not supported push service")
return HttpResponseBadRequest('something went wrong')
@@ -299,4 +307,4 @@ def vapid_key(request)-> HttpResponse:
:param request: the request of the client
:return: JsonResponse with the VAPID key
"""
- return JsonResponse({'vapid-key': settings.WEB_PUSH_CONFIG_PUBLIC_KEY})
\ No newline at end of file
+ return JsonResponse({'vapid-key': settings.WEB_PUSH_CONFIG_PUBLIC_KEY})
diff --git a/openAPI-docu.yaml b/openAPI-docu.yaml
index 1c52d4737..5bf9e7e08 100644
--- a/openAPI-docu.yaml
+++ b/openAPI-docu.yaml
@@ -369,6 +369,9 @@ components:
auth_key:
type: string
description: 16 byte authentication secret according to RFC 8291, in Base64 URL encoding. (Only for UNIFIED_PUSH_ENCRYPTED).
+ vapid_public_key:
+ type: string
+ description: VAPID public key used by the client, in Base64 URL encoding. (Only for UNIFIED_PUSH_ENCRYPTED).
alert_request: