diff --git a/loko/notifications/tests.py b/loko/notifications/tests.py index fe1ce3a..77423bb 100644 --- a/loko/notifications/tests.py +++ b/loko/notifications/tests.py @@ -287,19 +287,32 @@ class WebPushTests(TestCase): self.assertEqual(WebPushSubscription.objects.filter(user=self.user).count(), 0) def test_send_webpush_mocked(self): - from unittest.mock import patch + import base64 + import os + from unittest.mock import patch, MagicMock + from cryptography.hazmat.primitives.asymmetric import ec + from cryptography.hazmat.primitives import serialization from notifications.models import WebPushSubscription from notifications.webpush import send_webpush_to_user + fake_client_priv = ec.generate_private_key(ec.SECP256R1()) + fake_pub = fake_client_priv.public_key().public_bytes( + encoding=serialization.Encoding.X962, + format=serialization.PublicFormat.UncompressedPoint + ) + fake_auth = os.urandom(16) sub = WebPushSubscription.objects.create( user=self.user, endpoint='https://fcm.googleapis.com/fcm/send/mock-123', - p256dh='fake-key', - auth='fake-auth', + p256dh=base64.urlsafe_b64encode(fake_pub).decode('utf-8').rstrip('='), + auth=base64.urlsafe_b64encode(fake_auth).decode('utf-8').rstrip('='), device_name='Test Device' ) - with patch('notifications.webpush.webpush') as mock_push: + mock_resp = MagicMock() + mock_resp.status_code = 201 + + with patch('notifications.webpush.requests.post', return_value=mock_resp) as mock_post: send_webpush_to_user( user=self.user, title="Alerte Intervention", @@ -307,28 +320,39 @@ class WebPushTests(TestCase): action_url="/interventions/1/", async_send=False ) - mock_push.assert_called_once() - called_kwargs = mock_push.call_args[1] - self.assertEqual(called_kwargs['subscription_info']['endpoint'], sub.endpoint) - self.assertIn("Alerte Intervention", called_kwargs['data']) + mock_post.assert_called_once() + called_endpoint = mock_post.call_args[0][0] + self.assertEqual(called_endpoint, sub.endpoint) + headers = mock_post.call_args[1]['headers'] + self.assertIn('Authorization', headers) + self.assertEqual(headers['Content-Encoding'], 'aes128gcm') def test_send_webpush_410_auto_cleanup(self): + import base64 + import os from unittest.mock import patch, MagicMock + from cryptography.hazmat.primitives.asymmetric import ec + from cryptography.hazmat.primitives import serialization from notifications.models import WebPushSubscription from notifications.webpush import send_webpush_to_user - from pywebpush import WebPushException + fake_client_priv = ec.generate_private_key(ec.SECP256R1()) + fake_pub = fake_client_priv.public_key().public_bytes( + encoding=serialization.Encoding.X962, + format=serialization.PublicFormat.UncompressedPoint + ) + fake_auth = os.urandom(16) sub = WebPushSubscription.objects.create( user=self.user, endpoint='https://fcm.googleapis.com/fcm/send/expired-123', - p256dh='fake-key', - auth='fake-auth', + p256dh=base64.urlsafe_b64encode(fake_pub).decode('utf-8').rstrip('='), + auth=base64.urlsafe_b64encode(fake_auth).decode('utf-8').rstrip('='), ) mock_resp = MagicMock() mock_resp.status_code = 410 - with patch('notifications.webpush.webpush', side_effect=WebPushException("Subscription expired", response=mock_resp)): + with patch('notifications.webpush.requests.post', return_value=mock_resp): send_webpush_to_user( user=self.user, title="Test", diff --git a/loko/notifications/views.py b/loko/notifications/views.py index c9611c6..45c11d2 100644 --- a/loko/notifications/views.py +++ b/loko/notifications/views.py @@ -811,7 +811,7 @@ def api_webpush_test(request): title=_("Test de notification Loko"), body=_("Les notifications instantanées sur votre appareil fonctionnent correctement !"), action_url='/notifications/settings/', - async_send=False, + async_send=True, ) return JsonResponse({'status': 'success', 'message': _("Notification de test envoyée !")}) diff --git a/loko/notifications/webpush.py b/loko/notifications/webpush.py index 5222a8f..297c9e7 100644 --- a/loko/notifications/webpush.py +++ b/loko/notifications/webpush.py @@ -1,18 +1,96 @@ +import base64 import json import logging -import re +import os +import struct +import time from threading import Thread +from urllib.parse import urlparse +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.kdf.hkdf import HKDF from django.conf import settings from django.utils import timezone from py_vapid import Vapid -from pywebpush import webpush, WebPushException +import requests from .models import WebPushSubscription logger = logging.getLogger(__name__) +def b64_urlsafe_decode(data) -> bytes: + """Décode une chaîne ou des octets base64url en complétant le padding si nécessaire.""" + if isinstance(data, str): + data = data.encode('ascii') + rem = len(data) % 4 + if rem > 0: + data += b'=' * (4 - rem) + return base64.urlsafe_b64decode(data) + + +def encrypt_webpush_rfc8291(plaintext: bytes, receiver_pub_bytes: bytes, auth_secret: bytes, salt: bytes = None) -> bytes: + """ + Chiffre une charge utile selon la norme Web Push RFC 8291 / RFC 8188 (aes128gcm). + Utilise directement la bibliothèque standard `cryptography` pour une fiabilité absolue + sans dépendance C tierce instable. + """ + if salt is None: + salt = os.urandom(16) + + # 1. Clé éphémère du serveur (P-256) + server_priv = ec.generate_private_key(ec.SECP256R1()) + server_pub = server_priv.public_key() + server_pub_bytes = server_pub.public_bytes( + encoding=serialization.Encoding.X962, + format=serialization.PublicFormat.UncompressedPoint, + ) + + # 2. Clé publique du client (navigateur) et calcul du secret partagé ECDH + receiver_pub = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), receiver_pub_bytes) + ecdh_secret = server_priv.exchange(ec.ECDH(), receiver_pub) + + # 3. Dérivation IKM (Input Keying Material) + auth_info = b'WebPush: info\x00' + receiver_pub_bytes + server_pub_bytes + ikm = HKDF( + algorithm=hashes.SHA256(), + length=32, + salt=auth_secret, + info=auth_info, + ).derive(ecdh_secret) + + # 4. Dérivation CEK (Content Encryption Key) + cek_info = b'Content-Encoding: aes128gcm\x00' + cek = HKDF( + algorithm=hashes.SHA256(), + length=16, + salt=salt, + info=cek_info, + ).derive(ikm) + + # 5. Dérivation Nonce + nonce_info = b'Content-Encoding: nonce\x00' + nonce = HKDF( + algorithm=hashes.SHA256(), + length=12, + salt=salt, + info=nonce_info, + ).derive(ikm) + + # 6. En-tête de message RFC 8188: salt (16 octets) + rs (4 octets, 4096) + idlen (1 octet) + keyid (65 octets) + rs = 4096 + header = salt + struct.pack('>I', rs) + bytes([len(server_pub_bytes)]) + server_pub_bytes + + # 7. Chiffrement AES-GCM (avec marqueur de fin 0x02 pour le dernier bloc) + record = plaintext + b'\x02' + aesgcm = AESGCM(cek) + ciphertext = aesgcm.encrypt(nonce, record, associated_data=None) + + return header + ciphertext + + def parse_device_info(user_agent: str) -> dict: """ Analyse succincte du User-Agent pour extraire le navigateur et le système d'exploitation. @@ -70,13 +148,21 @@ def get_vapid_key_obj(): if not priv_key: return None + if isinstance(priv_key, Vapid): + return priv_key + try: - if isinstance(priv_key, str) and 'BEGIN PRIVATE KEY' in priv_key: - return Vapid.from_pem(priv_key.encode('utf-8')) - elif isinstance(priv_key, str) and (priv_key.endswith('.pem') or priv_key.endswith('.key')): - return Vapid.from_file(priv_key) - else: - return Vapid.from_string(priv_key) + if isinstance(priv_key, str): + # Nettoyer les sauts de ligne échappés provenant des variables d'environnement + cleaned_key = priv_key.replace('\\n', '\n').strip() + if 'BEGIN PRIVATE KEY' in cleaned_key or 'BEGIN EC PRIVATE KEY' in cleaned_key: + return Vapid.from_pem(cleaned_key.encode('utf-8')) + elif os.path.exists(cleaned_key): + return Vapid.from_file(cleaned_key) + else: + return Vapid.from_string(cleaned_key) + elif isinstance(priv_key, bytes): + return Vapid.from_pem(priv_key) except Exception as exc: logger.error("Impossible de charger la clé privée VAPID: %s", exc) return None @@ -92,35 +178,50 @@ def get_vapid_claims(): def _send_single_push(subscription: WebPushSubscription, payload: dict, vapid_obj): """Envoie un message push chiffré à un abonnement individuel.""" - claims = get_vapid_claims() - subscription_info = { - 'endpoint': subscription.endpoint, - 'keys': { - 'p256dh': subscription.p256dh, - 'auth': subscription.auth, - }, - } - try: - webpush( - subscription_info=subscription_info, - data=json.dumps(payload), - vapid_private_key=vapid_obj, - vapid_claims=claims, - timeout=8.0, - ttl=86400, - ) - logger.info("Notification push envoyée avec succès à %s (%s)", subscription.user.username, subscription.device_name) - except WebPushException as exc: - if exc.response is not None and exc.response.status_code in (404, 410): + endpoint = subscription.endpoint + p256dh = subscription.p256dh + auth = subscription.auth + + if not endpoint or not p256dh or not auth: + logger.warning("Abonnement WebPush incomplet pour %s (ID %s)", subscription.user.username, subscription.pk) + return + + receiver_pub_bytes = b64_urlsafe_decode(p256dh) + auth_secret = b64_urlsafe_decode(auth) + + payload_bytes = json.dumps(payload).encode('utf-8') + encrypted_body = encrypt_webpush_rfc8291(payload_bytes, receiver_pub_bytes, auth_secret) + + parsed_url = urlparse(endpoint) + aud = f"{parsed_url.scheme}://{parsed_url.netloc}" + + claims = get_vapid_claims() + claims['aud'] = aud + claims['exp'] = int(time.time()) + (12 * 3600) + + vapid_headers = vapid_obj.sign(claims) + headers = { + **vapid_headers, + 'Content-Type': 'application/octet-stream', + 'Content-Encoding': 'aes128gcm', + 'TTL': '86400', + 'Urgency': 'high', + } + + resp = requests.post(endpoint, data=encrypted_body, headers=headers, timeout=10.0) + + if resp.status_code in (200, 201, 202): + logger.info("Notification push envoyée avec succès à %s (%s)", subscription.user.username, subscription.device_name) + elif resp.status_code in (404, 410): # L'abonnement a expiré ou a été révoqué par le navigateur - logger.info("Suppression de l'abonnement push expiré pour %s (HTTP %s)", subscription.user.username, exc.response.status_code) + logger.info("Suppression de l'abonnement push expiré pour %s (HTTP %s)", subscription.user.username, resp.status_code) try: subscription.delete() except Exception: pass else: - logger.warning("Erreur WebPushException pour %s: %s", subscription.user.username, exc) + logger.warning("Erreur envoi WebPush pour %s (HTTP %s): %s", subscription.user.username, resp.status_code, resp.text[:200]) except Exception as exc: logger.warning("Erreur inattendue envoi WebPush pour %s: %s", subscription.user.username, exc) diff --git a/requirements/base.txt b/requirements/base.txt index 366cfb6..90bcee5 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -45,3 +45,4 @@ markdown onnxruntime>=1.19.0 rapidocr-onnxruntime>=1.2.0 pywebpush>=2.0.0 +py-vapid>=1.9.0