fix(webpush): robust pure cryptography RFC8291 encryption and async test dispatch
This commit is contained in:
parent
589c84b203
commit
5757b28907
4 changed files with 169 additions and 43 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 !")})
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -45,3 +45,4 @@ markdown
|
|||
onnxruntime>=1.19.0
|
||||
rapidocr-onnxruntime>=1.2.0
|
||||
pywebpush>=2.0.0
|
||||
py-vapid>=1.9.0
|
||||
|
|
|
|||
Loading…
Reference in a new issue