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)
|
self.assertEqual(WebPushSubscription.objects.filter(user=self.user).count(), 0)
|
||||||
|
|
||||||
def test_send_webpush_mocked(self):
|
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.models import WebPushSubscription
|
||||||
from notifications.webpush import send_webpush_to_user
|
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(
|
sub = WebPushSubscription.objects.create(
|
||||||
user=self.user,
|
user=self.user,
|
||||||
endpoint='https://fcm.googleapis.com/fcm/send/mock-123',
|
endpoint='https://fcm.googleapis.com/fcm/send/mock-123',
|
||||||
p256dh='fake-key',
|
p256dh=base64.urlsafe_b64encode(fake_pub).decode('utf-8').rstrip('='),
|
||||||
auth='fake-auth',
|
auth=base64.urlsafe_b64encode(fake_auth).decode('utf-8').rstrip('='),
|
||||||
device_name='Test Device'
|
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(
|
send_webpush_to_user(
|
||||||
user=self.user,
|
user=self.user,
|
||||||
title="Alerte Intervention",
|
title="Alerte Intervention",
|
||||||
|
|
@ -307,28 +320,39 @@ class WebPushTests(TestCase):
|
||||||
action_url="/interventions/1/",
|
action_url="/interventions/1/",
|
||||||
async_send=False
|
async_send=False
|
||||||
)
|
)
|
||||||
mock_push.assert_called_once()
|
mock_post.assert_called_once()
|
||||||
called_kwargs = mock_push.call_args[1]
|
called_endpoint = mock_post.call_args[0][0]
|
||||||
self.assertEqual(called_kwargs['subscription_info']['endpoint'], sub.endpoint)
|
self.assertEqual(called_endpoint, sub.endpoint)
|
||||||
self.assertIn("Alerte Intervention", called_kwargs['data'])
|
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):
|
def test_send_webpush_410_auto_cleanup(self):
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
from unittest.mock import patch, MagicMock
|
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.models import WebPushSubscription
|
||||||
from notifications.webpush import send_webpush_to_user
|
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(
|
sub = WebPushSubscription.objects.create(
|
||||||
user=self.user,
|
user=self.user,
|
||||||
endpoint='https://fcm.googleapis.com/fcm/send/expired-123',
|
endpoint='https://fcm.googleapis.com/fcm/send/expired-123',
|
||||||
p256dh='fake-key',
|
p256dh=base64.urlsafe_b64encode(fake_pub).decode('utf-8').rstrip('='),
|
||||||
auth='fake-auth',
|
auth=base64.urlsafe_b64encode(fake_auth).decode('utf-8').rstrip('='),
|
||||||
)
|
)
|
||||||
|
|
||||||
mock_resp = MagicMock()
|
mock_resp = MagicMock()
|
||||||
mock_resp.status_code = 410
|
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(
|
send_webpush_to_user(
|
||||||
user=self.user,
|
user=self.user,
|
||||||
title="Test",
|
title="Test",
|
||||||
|
|
|
||||||
|
|
@ -811,7 +811,7 @@ def api_webpush_test(request):
|
||||||
title=_("Test de notification Loko"),
|
title=_("Test de notification Loko"),
|
||||||
body=_("Les notifications instantanées sur votre appareil fonctionnent correctement !"),
|
body=_("Les notifications instantanées sur votre appareil fonctionnent correctement !"),
|
||||||
action_url='/notifications/settings/',
|
action_url='/notifications/settings/',
|
||||||
async_send=False,
|
async_send=True,
|
||||||
)
|
)
|
||||||
return JsonResponse({'status': 'success', 'message': _("Notification de test envoyée !")})
|
return JsonResponse({'status': 'success', 'message': _("Notification de test envoyée !")})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,96 @@
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import os
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
from threading import Thread
|
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.conf import settings
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from py_vapid import Vapid
|
from py_vapid import Vapid
|
||||||
from pywebpush import webpush, WebPushException
|
import requests
|
||||||
|
|
||||||
from .models import WebPushSubscription
|
from .models import WebPushSubscription
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
def parse_device_info(user_agent: str) -> dict:
|
||||||
"""
|
"""
|
||||||
Analyse succincte du User-Agent pour extraire le navigateur et le système d'exploitation.
|
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:
|
if not priv_key:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
if isinstance(priv_key, Vapid):
|
||||||
|
return priv_key
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if isinstance(priv_key, str) and 'BEGIN PRIVATE KEY' in priv_key:
|
if isinstance(priv_key, str):
|
||||||
return Vapid.from_pem(priv_key.encode('utf-8'))
|
# Nettoyer les sauts de ligne échappés provenant des variables d'environnement
|
||||||
elif isinstance(priv_key, str) and (priv_key.endswith('.pem') or priv_key.endswith('.key')):
|
cleaned_key = priv_key.replace('\\n', '\n').strip()
|
||||||
return Vapid.from_file(priv_key)
|
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:
|
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:
|
except Exception as exc:
|
||||||
logger.error("Impossible de charger la clé privée VAPID: %s", exc)
|
logger.error("Impossible de charger la clé privée VAPID: %s", exc)
|
||||||
return None
|
return None
|
||||||
|
|
@ -92,35 +178,50 @@ def get_vapid_claims():
|
||||||
|
|
||||||
def _send_single_push(subscription: WebPushSubscription, payload: dict, vapid_obj):
|
def _send_single_push(subscription: WebPushSubscription, payload: dict, vapid_obj):
|
||||||
"""Envoie un message push chiffré à un abonnement individuel."""
|
"""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()
|
claims = get_vapid_claims()
|
||||||
subscription_info = {
|
claims['aud'] = aud
|
||||||
'endpoint': subscription.endpoint,
|
claims['exp'] = int(time.time()) + (12 * 3600)
|
||||||
'keys': {
|
|
||||||
'p256dh': subscription.p256dh,
|
vapid_headers = vapid_obj.sign(claims)
|
||||||
'auth': subscription.auth,
|
headers = {
|
||||||
},
|
**vapid_headers,
|
||||||
|
'Content-Type': 'application/octet-stream',
|
||||||
|
'Content-Encoding': 'aes128gcm',
|
||||||
|
'TTL': '86400',
|
||||||
|
'Urgency': 'high',
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
resp = requests.post(endpoint, data=encrypted_body, headers=headers, timeout=10.0)
|
||||||
webpush(
|
|
||||||
subscription_info=subscription_info,
|
if resp.status_code in (200, 201, 202):
|
||||||
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)
|
logger.info("Notification push envoyée avec succès à %s (%s)", subscription.user.username, subscription.device_name)
|
||||||
except WebPushException as exc:
|
elif resp.status_code in (404, 410):
|
||||||
if exc.response is not None and exc.response.status_code in (404, 410):
|
|
||||||
# L'abonnement a expiré ou a été révoqué par le navigateur
|
# 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:
|
try:
|
||||||
subscription.delete()
|
subscription.delete()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
else:
|
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:
|
except Exception as exc:
|
||||||
logger.warning("Erreur inattendue envoi WebPush pour %s: %s", subscription.user.username, exc)
|
logger.warning("Erreur inattendue envoi WebPush pour %s: %s", subscription.user.username, exc)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,3 +45,4 @@ markdown
|
||||||
onnxruntime>=1.19.0
|
onnxruntime>=1.19.0
|
||||||
rapidocr-onnxruntime>=1.2.0
|
rapidocr-onnxruntime>=1.2.0
|
||||||
pywebpush>=2.0.0
|
pywebpush>=2.0.0
|
||||||
|
py-vapid>=1.9.0
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue