From 814f44c3fb9f40667c226a826b70fae0132cee1d Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Tue, 7 Apr 2026 06:59:06 -0400
Subject: [PATCH] fix(push): fix VAPID key format and add regenerate endpoint +
UI button
---
frontend/src/views/SettingsView.vue | 38 ++++++++++++++++++++++++++++
src/fabledassistant/routes/push.py | 14 ++++++++--
src/fabledassistant/services/push.py | 38 ++++++++++++++++++++++++----
3 files changed, 83 insertions(+), 7 deletions(-)
diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue
index 75e4b03..0c50c89 100644
--- a/frontend/src/views/SettingsView.vue
+++ b/frontend/src/views/SettingsView.vue
@@ -428,6 +428,28 @@ const notifySecurityAlerts = ref(true);
const savingNotifications = ref(false);
const notificationsSaved = ref(false);
+// VAPID key reset (admin)
+const vapidResetting = ref(false);
+const vapidResetMsg = ref("");
+const vapidResetError = ref(false);
+
+async function resetVapidKeys() {
+ if (!confirm("This will regenerate VAPID keys and clear all push subscriptions. You will need to re-enable notifications afterwards. Continue?")) return;
+ vapidResetting.value = true;
+ vapidResetMsg.value = "";
+ vapidResetError.value = false;
+ try {
+ await apiPost("/api/push/reset-vapid", {});
+ vapidResetMsg.value = "Keys regenerated. Please re-enable notifications in this browser.";
+ pushStore.checkSubscription();
+ } catch {
+ vapidResetError.value = true;
+ vapidResetMsg.value = "Reset failed — check server logs.";
+ } finally {
+ vapidResetting.value = false;
+ }
+}
+
// CalDAV settings (per-user)
const caldav = ref({
caldav_url: "",
@@ -1882,6 +1904,22 @@ function formatUserDate(iso: string): string {
Notifications are blocked. Allow them in your browser site settings to re-enable.
+
+
+
+ If push notifications fail due to a corrupted or misformatted VAPID key, regenerate
+ the key pair here. All existing subscriptions will be cleared — you will need to
+ re-enable notifications in this browser afterwards.
+
+
+
+
+
+ {{ vapidResetMsg }}
+
+
diff --git a/src/fabledassistant/routes/push.py b/src/fabledassistant/routes/push.py
index c827420..6179518 100644
--- a/src/fabledassistant/routes/push.py
+++ b/src/fabledassistant/routes/push.py
@@ -3,9 +3,9 @@ import logging
from quart import Blueprint, jsonify, request
-from fabledassistant.auth import login_required, get_current_user_id
+from fabledassistant.auth import login_required, get_current_user_id, admin_required
from fabledassistant.config import Config
-from fabledassistant.services.push import delete_subscription, save_subscription, vapid_enabled
+from fabledassistant.services.push import delete_subscription, regenerate_vapid_keys, save_subscription, vapid_enabled
logger = logging.getLogger(__name__)
@@ -42,3 +42,13 @@ async def unsubscribe():
return jsonify({"error": "endpoint is required"}), 400
await delete_subscription(uid, endpoint)
return "", 204
+
+
+@push_bp.route("/reset-vapid", methods=["POST"])
+@admin_required
+async def reset_vapid():
+ """Regenerate VAPID keys and clear all push subscriptions."""
+ ok = await regenerate_vapid_keys()
+ if ok:
+ return jsonify({"publicKey": Config.VAPID_PUBLIC_KEY}), 200
+ return jsonify({"error": "Key regeneration failed"}), 500
diff --git a/src/fabledassistant/services/push.py b/src/fabledassistant/services/push.py
index 87256ad..b6c7708 100644
--- a/src/fabledassistant/services/push.py
+++ b/src/fabledassistant/services/push.py
@@ -62,11 +62,14 @@ def ensure_vapid_keys() -> None:
v = Vapid01()
v.generate_keys()
- private_pem = v.private_key.private_bytes(
- serialization.Encoding.PEM,
+ # pywebpush expects the private key as a base64url-encoded DER blob
+ # (passed to Vapid.from_string → from_der), NOT a PEM string.
+ private_der = v.private_key.private_bytes(
+ serialization.Encoding.DER,
serialization.PrivateFormat.TraditionalOpenSSL,
serialization.NoEncryption(),
- ).decode()
+ )
+ private_b64 = base64.urlsafe_b64encode(private_der).rstrip(b"=").decode()
pub_bytes = v.public_key.public_bytes(
serialization.Encoding.X962,
@@ -76,15 +79,40 @@ def ensure_vapid_keys() -> None:
# Persist so they survive container restarts.
_VAPID_KEYS_FILE.parent.mkdir(parents=True, exist_ok=True)
- _VAPID_KEYS_FILE.write_text(json.dumps({"private_key": private_pem, "public_key": public_b64}))
+ _VAPID_KEYS_FILE.write_text(json.dumps({"private_key": private_b64, "public_key": public_b64}))
- Config.VAPID_PRIVATE_KEY = private_pem
+ Config.VAPID_PRIVATE_KEY = private_b64
Config.VAPID_PUBLIC_KEY = public_b64
logger.info("Generated new VAPID keys and saved to %s", _VAPID_KEYS_FILE)
except Exception:
logger.warning("Failed to generate VAPID keys — push notifications will be unavailable", exc_info=True)
+async def regenerate_vapid_keys() -> bool:
+ """Delete existing VAPID keys, clear all push subscriptions, and generate a fresh pair.
+
+ All existing browser subscriptions are invalidated when keys rotate, so they
+ must be cleared — users will need to re-enable notifications.
+ """
+ if _VAPID_KEYS_FILE.exists():
+ _VAPID_KEYS_FILE.unlink()
+ Config.VAPID_PRIVATE_KEY = ""
+ Config.VAPID_PUBLIC_KEY = ""
+
+ # Clear all push subscriptions — they are bound to the old public key.
+ async with async_session() as session:
+ await session.execute(delete(PushSubscription))
+ await session.commit()
+
+ ensure_vapid_keys()
+ enabled = vapid_enabled()
+ if enabled:
+ logger.info("VAPID keys regenerated successfully")
+ else:
+ logger.error("VAPID key regeneration failed")
+ return enabled
+
+
async def save_subscription(user_id: int, subscription_json: dict, user_agent: str | None = None) -> PushSubscription:
"""Upsert a push subscription by endpoint."""
endpoint = subscription_json.get("endpoint", "")