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.

+
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", "")