fix(webpush): robust pure cryptography RFC8291 encryption and async test dispatch

This commit is contained in:
kdeterme 2026-09-04 12:09:53 +02:00
parent 589c84b203
commit 5757b28907
4 changed files with 169 additions and 43 deletions

View file

@ -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",

View file

@ -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 !")})

View file

@ -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)
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(priv_key)
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."""
try:
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()
subscription_info = {
'endpoint': subscription.endpoint,
'keys': {
'p256dh': subscription.p256dh,
'auth': subscription.auth,
},
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',
}
try:
webpush(
subscription_info=subscription_info,
data=json.dumps(payload),
vapid_private_key=vapid_obj,
vapid_claims=claims,
timeout=8.0,
ttl=86400,
)
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)
except WebPushException as exc:
if exc.response is not None and exc.response.status_code in (404, 410):
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)

View file

@ -45,3 +45,4 @@ markdown
onnxruntime>=1.19.0
rapidocr-onnxruntime>=1.2.0
pywebpush>=2.0.0
py-vapid>=1.9.0