Benachrichtigungen: Web Push und E-Mail als Kanaele der Automatiken
Neuer BenachrichtigungTransport hinter der Aktor-URL "Benachrichtigung": push schickt an alle angemeldeten Geraete (homeMesh.push_abos), mail ueber den SMTP-Zugang aus der config.ini. Ein Abo, das der Push-Dienst mit 404 oder 410 ablehnt, wird geloescht statt weiter angeschrieben. Daneben hoert der Runner auf benachrichtigung/# - darueber schickt die Probe aus den Einstellungen, ohne dass die Weboberflaeche verschluesseln muesste. Zugestellt wird im Haupttakt, nicht im MQTT-Faden. pywebpush, py_vapid und http_ece liegen wie die uebrigen Fremdpakete im Ordner, nicht in site-packages; transports.py haengt ihn an den Suchpfad. Das Schluesselpaar erzeugt vapid_erzeugen.py und bleibt ausserhalb des Git. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
# Zugangsdaten - nichts davon gehoert ins Repository
|
||||
config.ini
|
||||
skoda.conf
|
||||
# Das VAPID-Schluesselpaar fuer Web Push. Der private Teil unterschreibt jede
|
||||
# Meldung an die Handys - er gehoert nicht ins Repository. Neu erzeugen:
|
||||
# python3 vapid_erzeugen.py (danach muessen sich alle Geraete neu anmelden).
|
||||
push_vapid.json
|
||||
*.conf
|
||||
secrets.conf
|
||||
|
||||
|
||||
@@ -75,7 +75,8 @@ import pymysql
|
||||
import requests
|
||||
import paho.mqtt.client as mqtt
|
||||
|
||||
from transports import (AUTOMATIK_URL, AutomatikTransport, HTTPTransport,
|
||||
from transports import (AUTOMATIK_URL, BENACHRICHTIGUNG_TOPIC,
|
||||
BenachrichtigungTransport, AutomatikTransport, HTTPTransport,
|
||||
LogicTransport, MQTTTransport, TahomaTransport,
|
||||
WLEDTransport, ausloeser_kennung, ausloeser_url,
|
||||
ist_topic)
|
||||
@@ -113,6 +114,18 @@ class Config:
|
||||
except ValueError:
|
||||
return vorgabe
|
||||
|
||||
def abschnitt(self, sektion):
|
||||
"""
|
||||
Einen ganzen Abschnitt als Dict.
|
||||
|
||||
Fuer den Mailzugang: der besteht aus einem halben Dutzend Feldern und
|
||||
wird als Ganzes an den Transport weitergereicht, statt sechsmal
|
||||
einzeln abgefragt zu werden.
|
||||
"""
|
||||
if not self.cfg.has_section(sektion):
|
||||
return {}
|
||||
return {k: v.strip() for k, v in self.cfg.items(sektion)}
|
||||
|
||||
def ja(self, sektion, schluessel, vorgabe=False):
|
||||
return self.text(sektion, schluessel, str(vorgabe)).lower() in ("true", "1", "yes", "on")
|
||||
|
||||
@@ -755,7 +768,25 @@ class Runner:
|
||||
config.zahl("tahoma", "timeout", 10), self.dry_run),
|
||||
LogicTransport(self.sonnenzeiten),
|
||||
AutomatikTransport(self.ausloesezeiten),
|
||||
BenachrichtigungTransport(self.push_abos, self.push_abo_weg,
|
||||
self.push_abo_erfolg,
|
||||
config.abschnitt("mail"),
|
||||
# Ein leerer Eintrag in der config.ini ist
|
||||
# vorhanden, aber leer - die Vorgabe von
|
||||
# text() greift dann nicht.
|
||||
(config.text("push", "schluessel")
|
||||
or os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||
"..", "push_vapid.json")),
|
||||
self.dry_run),
|
||||
]
|
||||
# Meldungen, die nicht aus einer Automatik kommen: die Probe aus den
|
||||
# Einstellungen, spaeter vielleicht ein Skript. Derselbe Weg, dieselbe
|
||||
# Zustellung - nur ohne Umweg ueber eine Regel.
|
||||
self.meldungen = queue.Queue()
|
||||
try:
|
||||
self.mqtt.subscribe(BENACHRICHTIGUNG_TOPIC)
|
||||
except Exception as fehler:
|
||||
logger.warning("Meldungs-Topic nicht abonnierbar: %r", fehler)
|
||||
self.regelwerk = None
|
||||
|
||||
# --- Aufbau ----------------------------------------------------------
|
||||
@@ -774,10 +805,83 @@ class Runner:
|
||||
|
||||
def _mqtt_nachricht(self, client, userdata, nachricht):
|
||||
# Der Client laeuft schon, waehrend __init__ noch die Transporte baut.
|
||||
if nachricht.topic.startswith("benachrichtigung/"):
|
||||
# Nicht hier zustellen: Dieser Rueckruf laeuft im MQTT-Faden, und
|
||||
# eine Datenbankverbindung gehoert einem Faden. Der naechste Takt
|
||||
# holt es ab.
|
||||
schlange = getattr(self, "meldungen", None)
|
||||
if schlange is not None:
|
||||
schlange.put((nachricht.topic.rsplit("/", 1)[-1], nachricht.payload))
|
||||
return
|
||||
for transport in getattr(self, "transporte", []):
|
||||
if isinstance(transport, MQTTTransport):
|
||||
transport.nachricht(nachricht.topic, nachricht.payload)
|
||||
|
||||
# --- Benachrichtigungen ----------------------------------------------
|
||||
|
||||
def benachrichtigung(self):
|
||||
"""Der Transport fuer die Meldungen, oder None."""
|
||||
for transport in self.transporte:
|
||||
if isinstance(transport, BenachrichtigungTransport):
|
||||
return transport
|
||||
return None
|
||||
|
||||
def meldungen_abarbeiten(self):
|
||||
"""
|
||||
Was ueber benachrichtigung/# hereinkam, zustellen.
|
||||
|
||||
Nutzlast ist JSON ({"titel": ..., "text": ...}); ein blosser Text
|
||||
geht auch durch und wird zum Textkoerper.
|
||||
"""
|
||||
transport = self.benachrichtigung()
|
||||
while True:
|
||||
try:
|
||||
kanal, rohtext = self.meldungen.get_nowait()
|
||||
except queue.Empty:
|
||||
return
|
||||
if transport is None:
|
||||
continue
|
||||
try:
|
||||
text = rohtext.decode("utf-8", "replace") if isinstance(rohtext, bytes) else str(rohtext)
|
||||
try:
|
||||
daten = json.loads(text)
|
||||
if not isinstance(daten, dict):
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
daten = {"text": text}
|
||||
if kanal == "mail":
|
||||
transport.mail_senden(daten.get("betreff") or daten.get("titel") or "Smarthome",
|
||||
daten.get("text") or "")
|
||||
else:
|
||||
transport.push(daten.get("titel") or "Smarthome", daten.get("text") or "")
|
||||
except Exception as fehler:
|
||||
logger.warning("Meldung (%s) nicht zustellbar: %r", kanal, fehler)
|
||||
|
||||
def push_abos(self):
|
||||
"""Die angemeldeten Geraete. Fehlt die Tabelle, gibt es eben keine."""
|
||||
try:
|
||||
with self.db.cursor() as c:
|
||||
c.execute("SELECT id, endpoint, p256dh, auth, name FROM push_abos")
|
||||
return list(c.fetchall())
|
||||
except Exception as fehler:
|
||||
logger.debug("push_abos nicht lesbar: %r", fehler)
|
||||
return []
|
||||
|
||||
def push_abo_weg(self, abo_id, grund):
|
||||
try:
|
||||
with self.db.cursor() as c:
|
||||
c.execute("DELETE FROM push_abos WHERE id = %s", (abo_id,))
|
||||
except Exception as fehler:
|
||||
logger.warning("Push-Abo %s nicht loeschbar: %r", abo_id, fehler)
|
||||
|
||||
def push_abo_erfolg(self, abo_id):
|
||||
try:
|
||||
with self.db.cursor() as c:
|
||||
c.execute("UPDATE push_abos SET zuletzt = NOW(), fehler = '' WHERE id = %s",
|
||||
(abo_id,))
|
||||
except Exception as fehler:
|
||||
logger.debug("Push-Abo %s nicht fortschreibbar: %r", abo_id, fehler)
|
||||
|
||||
def transport_fuer(self, actor_url):
|
||||
for transport in self.transporte:
|
||||
if transport.passt(actor_url):
|
||||
@@ -1311,6 +1415,7 @@ class Runner:
|
||||
while True:
|
||||
jetzt = time.monotonic()
|
||||
self.werte_einsammeln(auch_geraete=nur_einmal)
|
||||
self.meldungen_abarbeiten()
|
||||
self.durchlauf(fenster)
|
||||
self.ergebnisse_verbuchen()
|
||||
self.protokoll_saeubern()
|
||||
|
||||
@@ -85,3 +85,32 @@ dry_run = false
|
||||
|
||||
# DEBUG, INFO, WARNING, ERROR
|
||||
log_level = INFO
|
||||
|
||||
# ============================================================================
|
||||
# BENACHRICHTIGUNGEN - E-Mail
|
||||
# ============================================================================
|
||||
# Fuer das Kommando "E-Mail" des Geraets "Benachrichtigungen". Ohne Server
|
||||
# und Empfaenger meldet die Automatik einen Fehler ins Protokoll, statt still
|
||||
# nichts zu tun.
|
||||
#
|
||||
# port 587 beginnt im Klartext und wechselt mit STARTTLS (der Normalfall)
|
||||
# port 465 spricht von Anfang an verschluesselt
|
||||
#
|
||||
# Mehrere Empfaenger mit Komma trennen. "von" ist der Absender; viele Anbieter
|
||||
# verlangen, dass er gleich dem Anmeldenamen ist.
|
||||
[mail]
|
||||
server =
|
||||
port = 587
|
||||
benutzer =
|
||||
passwort =
|
||||
von =
|
||||
an =
|
||||
|
||||
# ============================================================================
|
||||
# BENACHRICHTIGUNGEN - Web Push
|
||||
# ============================================================================
|
||||
# Das Schluesselpaar erzeugt einmalig SolarManager/vapid_erzeugen.py. Der
|
||||
# Pfad muss nur gesetzt werden, wenn die Datei woanders liegt als
|
||||
# SolarManager/push_vapid.json.
|
||||
[push]
|
||||
schluessel =
|
||||
|
||||
+187
-4
@@ -40,7 +40,9 @@ id, `aktion` ein Dict mit actor_url, command_url und params (Liste aus
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import urllib3
|
||||
from datetime import datetime
|
||||
@@ -48,6 +50,14 @@ from urllib.parse import quote
|
||||
|
||||
logger = logging.getLogger("autoaction.transport")
|
||||
|
||||
# Die Pakete fuer Web Push (pywebpush, py_vapid, http_ece) liegen wie alle
|
||||
# anderen Fremdpakete im SolarManager-Ordner, nicht in site-packages. Der
|
||||
# Runner startet aber aus autoActions/ heraus - ohne diese Zeile faende er
|
||||
# sie nicht.
|
||||
_SOLARMANAGER = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if _SOLARMANAGER not in sys.path:
|
||||
sys.path.append(_SOLARMANAGER)
|
||||
|
||||
# Die Tahoma-Box hat ein selbst ausgestelltes Zertifikat auf einen Namen, den
|
||||
# nur das Heimnetz kennt. Die Pruefung ist dort bewusst aus (wie in
|
||||
# ajax/tahoma.php); ohne diese Zeile warnt urllib3 bei jeder einzelnen
|
||||
@@ -600,9 +610,17 @@ class TahomaTransport(Transport):
|
||||
|
||||
if umweg:
|
||||
self._apply(aktion["actor_url"], befehl, vorstufe)
|
||||
self._warteAufJalousie(aktion["actor_url"], 0,
|
||||
if(not self._warteAufJalousie(aktion["actor_url"], aktion["actor_name"]+" 1.Versuch", 0,
|
||||
None if position_index is None else int(parameter[position_index]))):
|
||||
self._apply(aktion["actor_url"], befehl, vorstufe)
|
||||
self._warteAufJalousie(aktion["actor_url"], aktion["actor_name"]+" 2.Versuch", 0,
|
||||
None if position_index is None else int(parameter[position_index]))
|
||||
self._apply(aktion["actor_url"], befehl, parameter)
|
||||
if(not self._warteAufJalousie(aktion["actor_url"], aktion["actor_name"]+" 1.VersuchEndPos", 0,
|
||||
None if position_index is None else int(parameter[position_index]))):
|
||||
self._apply(aktion["actor_url"], befehl, parameter)
|
||||
self._warteAufJalousie(aktion["actor_url"], aktion["actor_name"]+" 2.VersuchEndPos", 0,
|
||||
None if position_index is None else int(parameter[position_index]))
|
||||
|
||||
def _kannKombi(self, actor_url):
|
||||
"""
|
||||
@@ -622,7 +640,7 @@ class TahomaTransport(Transport):
|
||||
raise RuntimeError("Tahoma antwortete mit %d: %s"
|
||||
% (antwort.status_code, antwort.text[:120]))
|
||||
|
||||
def _warteAufJalousie(self, actor_url, neigung_ziel, schliessung_ziel=None):
|
||||
def _warteAufJalousie(self, actor_url, actor_name, neigung_ziel, schliessung_ziel=None):
|
||||
"""
|
||||
Wartet, bis die Jalousie ihre Fahrt beendet hat und die Ziele zeigt.
|
||||
|
||||
@@ -654,8 +672,8 @@ class TahomaTransport(Transport):
|
||||
if neigung_ok and schliessung_ok and (
|
||||
gestartet or time.time() - start >= self.JALOUSIE_VORLAUF_SEKUNDEN):
|
||||
return True
|
||||
logger.warning("%s hat Neigung %s%% nicht innerhalb von %d s erreicht",
|
||||
actor_url, neigung_ziel, self.JALOUSIE_WARTE_SEKUNDEN)
|
||||
logger.warning("%s(%s) hat Neigung %s%% nicht innerhalb von %d s erreicht",
|
||||
actor_url,actor_name, neigung_ziel, self.JALOUSIE_WARTE_SEKUNDEN)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
@@ -795,3 +813,168 @@ class AutomatikTransport(Transport):
|
||||
|
||||
def senden(self, aktion):
|
||||
raise RuntimeError("Das Geraet \"Automatiken\" kann nichts schalten")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Benachrichtigungen
|
||||
# ===========================================================================
|
||||
|
||||
BENACHRICHTIGUNG_URL = "Benachrichtigung"
|
||||
|
||||
#: Themen, auf denen auch andere Stellen eine Meldung anstossen koennen - die
|
||||
#: Probe aus den Einstellungen nimmt diesen Weg (restricted/push.php).
|
||||
BENACHRICHTIGUNG_TOPIC = "benachrichtigung/#"
|
||||
|
||||
|
||||
class BenachrichtigungTransport(Transport):
|
||||
"""
|
||||
Meldungen aufs Handy (Web Push) und per E-Mail.
|
||||
|
||||
Ein gerechnetes Geraet wie LogicTransport: Es steht hinter der Aktor-URL
|
||||
"Benachrichtigung" und hat keine Messwerte, nur Kommandos - eines je
|
||||
Kanal. Fuer den Editor ist das ein Geraet wie jedes andere, deshalb
|
||||
braucht die Weboberflaeche dafuer keine Zeile.
|
||||
|
||||
Web Push braucht drei Dinge, die alle schon da sind: ein Abo je Geraet
|
||||
(homeMesh.push_abos, angelegt vom Browser), ein VAPID-Schluesselpaar
|
||||
(push_vapid.json) und die Pakete im SolarManager-Ordner. Verschluesselt
|
||||
wird gegen die Schluessel des Geraets - der Push-Dienst des Herstellers
|
||||
sieht nur ein Paket, das er nicht lesen kann.
|
||||
|
||||
Ein Abo, das der Dienst mit 404 oder 410 ablehnt, gibt es nicht mehr
|
||||
(Browserdaten geloescht, Symbol entfernt). Es wird dann geloescht statt
|
||||
ewig weiter angeschrieben: sonst haengt an jeder Meldung ein Fehler, den
|
||||
niemand beheben kann.
|
||||
"""
|
||||
|
||||
schema = "benachrichtigung"
|
||||
|
||||
def __init__(self, abos_lesen, abo_weg, abo_erfolg, mail_konfig, vapid_datei,
|
||||
dry_run=False):
|
||||
self.abos_lesen = abos_lesen # () -> [{id, endpoint, p256dh, auth}]
|
||||
self.abo_weg = abo_weg # (id, grund) -> None
|
||||
self.abo_erfolg = abo_erfolg # (id) -> None
|
||||
self.mail = mail_konfig or {}
|
||||
self.vapid_datei = vapid_datei
|
||||
self.dry_run = dry_run
|
||||
self._vapid = None
|
||||
|
||||
def passt(self, actor_url):
|
||||
return str(actor_url or "") == BENACHRICHTIGUNG_URL
|
||||
|
||||
def zustaende_anmelden(self, states):
|
||||
pass # nichts zu lesen - das Geraet meldet nichts
|
||||
|
||||
def zustaende_lesen(self):
|
||||
return {}
|
||||
|
||||
# --- Kanaele ---------------------------------------------------------
|
||||
|
||||
def senden(self, aktion):
|
||||
kanal = str(aktion.get("command_url") or "")
|
||||
werte = {p.get("url") or p.get("name"): str(p.get("wert") or "")
|
||||
for p in aktion.get("params") or []}
|
||||
if kanal == "push":
|
||||
self.push(werte.get("titel") or "Smarthome", werte.get("text") or "")
|
||||
elif kanal == "mail":
|
||||
self.mail_senden(werte.get("betreff") or "Smarthome", werte.get("text") or "")
|
||||
else:
|
||||
raise ValueError("Unbekannter Kanal: %s" % kanal)
|
||||
|
||||
def vapid(self):
|
||||
"""
|
||||
Das Schluesselpaar, einmal gelesen und in ein Vapid-Objekt gepackt.
|
||||
|
||||
pywebpush nimmt als Schluessel entweder einen Dateipfad, eine
|
||||
base64-Zeichenkette oder ein fertiges Vapid-Objekt - aber nicht den
|
||||
PEM-Text selbst. Der steht in push_vapid.json, also wird er hier
|
||||
einmal eingelesen.
|
||||
"""
|
||||
if self._vapid is None:
|
||||
from py_vapid import Vapid01
|
||||
with open(self.vapid_datei) as f:
|
||||
daten = json.load(f)
|
||||
daten["vapid"] = Vapid01.from_pem(daten["private_key_pem"].encode("utf-8"))
|
||||
self._vapid = daten
|
||||
return self._vapid
|
||||
|
||||
def push(self, titel, text):
|
||||
"""An alle angemeldeten Geraete."""
|
||||
from pywebpush import webpush, WebPushException
|
||||
|
||||
abos = self.abos_lesen()
|
||||
if not abos:
|
||||
logger.info("Push \"%s\": kein Geraet angemeldet", titel)
|
||||
return
|
||||
if self.dry_run:
|
||||
logger.info("[dry-run] Push an %d Geraet(e): %s / %s", len(abos), titel, text)
|
||||
return
|
||||
|
||||
schluessel = self.vapid()
|
||||
nutzlast = json.dumps({"titel": titel, "text": text, "url": "index.php"},
|
||||
ensure_ascii=False)
|
||||
fehler = []
|
||||
for abo in abos:
|
||||
try:
|
||||
webpush(
|
||||
subscription_info={
|
||||
"endpoint": abo["endpoint"],
|
||||
"keys": {"p256dh": abo["p256dh"], "auth": abo["auth"]},
|
||||
},
|
||||
data=nutzlast,
|
||||
vapid_private_key=schluessel["vapid"],
|
||||
vapid_claims={"sub": schluessel.get("subject", "mailto:admin@example.org")},
|
||||
timeout=10,
|
||||
)
|
||||
self.abo_erfolg(abo["id"])
|
||||
except WebPushException as f:
|
||||
code = getattr(getattr(f, "response", None), "status_code", 0)
|
||||
if code in (404, 410):
|
||||
self.abo_weg(abo["id"], "vom Push-Dienst abgemeldet (%d)" % code)
|
||||
logger.info("Push-Abo %s ist weg (%d), geloescht", abo["id"], code)
|
||||
else:
|
||||
fehler.append("%s: %s" % (abo.get("name") or abo["id"], f))
|
||||
except Exception as f: # Netz, Zeitlimit, Schluessel
|
||||
fehler.append("%s: %r" % (abo.get("name") or abo["id"], f))
|
||||
if fehler:
|
||||
raise RuntimeError("; ".join(fehler)[:250])
|
||||
|
||||
def mail_senden(self, betreff, text):
|
||||
"""Eine Mail ueber den SMTP-Zugang aus der config.ini."""
|
||||
import smtplib
|
||||
import ssl
|
||||
from email.message import EmailMessage
|
||||
|
||||
server = self.mail.get("server") or ""
|
||||
an = [a.strip() for a in (self.mail.get("an") or "").split(",") if a.strip()]
|
||||
if not server or not an:
|
||||
raise RuntimeError("Kein Mailzugang eingetragen (Abschnitt [mail] in config.ini)")
|
||||
if self.dry_run:
|
||||
logger.info("[dry-run] Mail an %s: %s / %s", ", ".join(an), betreff, text)
|
||||
return
|
||||
|
||||
nachricht = EmailMessage()
|
||||
nachricht["From"] = self.mail.get("von") or an[0]
|
||||
nachricht["To"] = ", ".join(an)
|
||||
nachricht["Subject"] = betreff
|
||||
nachricht.set_content(text)
|
||||
|
||||
port = int(self.mail.get("port") or 587)
|
||||
# Zwei Bauarten: 465 spricht von Anfang an verschluesselt, 587 beginnt
|
||||
# im Klartext und wechselt mit STARTTLS. Alles andere waere heute
|
||||
# unverschluesselter Versand - den gibt es hier nicht.
|
||||
if port == 465:
|
||||
verbindung = smtplib.SMTP_SSL(server, port, timeout=15,
|
||||
context=ssl.create_default_context())
|
||||
else:
|
||||
verbindung = smtplib.SMTP(server, port, timeout=15)
|
||||
verbindung.starttls(context=ssl.create_default_context())
|
||||
try:
|
||||
if self.mail.get("benutzer"):
|
||||
verbindung.login(self.mail["benutzer"], self.mail.get("passwort") or "")
|
||||
verbindung.send_message(nachricht)
|
||||
finally:
|
||||
try:
|
||||
verbindung.quit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
import functools
|
||||
import os
|
||||
import struct
|
||||
|
||||
from cryptography.exceptions import InvalidTag
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
|
||||
MAX_RECORD_SIZE = pow(2, 31) - 1
|
||||
MIN_RECORD_SIZE = 3
|
||||
KEY_LENGTH = 16
|
||||
NONCE_LENGTH = 12
|
||||
TAG_LENGTH = 16
|
||||
|
||||
# Valid content types (ordered from newest, to most obsolete)
|
||||
versions = {
|
||||
"aes128gcm": {"pad": 1},
|
||||
"aesgcm": {"pad": 2},
|
||||
"aesgcm128": {"pad": 1},
|
||||
}
|
||||
|
||||
|
||||
class ECEException(Exception):
|
||||
"""Exception for ECE encryption functions"""
|
||||
|
||||
def __init__(self, message):
|
||||
self.message = message
|
||||
|
||||
|
||||
def derive_key(
|
||||
mode, version, salt, key, private_key, dh, auth_secret, keyid, keylabel="P-256"
|
||||
):
|
||||
"""Derive the encryption key
|
||||
|
||||
:param mode: operational mode (encrypt or decrypt)
|
||||
:type mode: enumerate('encrypt', 'decrypt)
|
||||
:param salt: encryption salt value
|
||||
:type salt: str
|
||||
:param key: raw key
|
||||
:type key: str
|
||||
:param private_key: DH private key
|
||||
:type key: object
|
||||
:param dh: Diffie Helman public key value
|
||||
:type dh: str
|
||||
:param keyid: key identifier label
|
||||
:type keyid: str
|
||||
:param keylabel: label for aesgcm/aesgcm128
|
||||
:type keylabel: str
|
||||
:param auth_secret: authorization secret
|
||||
:type auth_secret: str
|
||||
:param version: Content Type identifier
|
||||
:type version: enumerate('aes128gcm', 'aesgcm', 'aesgcm128')
|
||||
|
||||
"""
|
||||
context = b""
|
||||
keyinfo = ""
|
||||
nonceinfo = ""
|
||||
|
||||
def build_info(base, info_context):
|
||||
return b"Content-Encoding: " + base + b"\0" + info_context
|
||||
|
||||
def derive_dh(mode, version, private_key, dh, keylabel):
|
||||
def length_prefix(key):
|
||||
return struct.pack("!H", len(key)) + key
|
||||
|
||||
if isinstance(dh, ec.EllipticCurvePublicKey):
|
||||
pubkey = dh
|
||||
dh = dh.public_bytes(Encoding.X962, PublicFormat.UncompressedPoint)
|
||||
else:
|
||||
pubkey = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), dh)
|
||||
|
||||
encoded = private_key.public_key().public_bytes(
|
||||
Encoding.X962, PublicFormat.UncompressedPoint
|
||||
)
|
||||
if mode == "encrypt":
|
||||
sender_pub_key = encoded
|
||||
receiver_pub_key = dh
|
||||
else:
|
||||
sender_pub_key = dh
|
||||
receiver_pub_key = encoded
|
||||
|
||||
if version == "aes128gcm":
|
||||
context = b"WebPush: info\x00" + receiver_pub_key + sender_pub_key
|
||||
else:
|
||||
context = (
|
||||
keylabel.encode("utf-8")
|
||||
+ b"\0"
|
||||
+ length_prefix(receiver_pub_key)
|
||||
+ length_prefix(sender_pub_key)
|
||||
)
|
||||
|
||||
return private_key.exchange(ec.ECDH(), pubkey), context
|
||||
|
||||
if version not in versions:
|
||||
raise ECEException("Invalid version")
|
||||
if mode not in ["encrypt", "decrypt"]:
|
||||
raise ECEException("unknown 'mode' specified: " + mode)
|
||||
if salt is None or len(salt) != KEY_LENGTH:
|
||||
raise ECEException("'salt' must be a 16 octet value")
|
||||
if dh is not None:
|
||||
if private_key is None:
|
||||
raise ECEException("DH requires a private_key")
|
||||
(secret, context) = derive_dh(
|
||||
mode=mode,
|
||||
version=version,
|
||||
private_key=private_key,
|
||||
dh=dh,
|
||||
keylabel=keylabel,
|
||||
)
|
||||
else:
|
||||
secret = key
|
||||
|
||||
if secret is None:
|
||||
raise ECEException("unable to determine the secret")
|
||||
|
||||
if version == "aesgcm":
|
||||
keyinfo = build_info(b"aesgcm", context)
|
||||
nonceinfo = build_info(b"nonce", context)
|
||||
elif version == "aesgcm128":
|
||||
keyinfo = b"Content-Encoding: aesgcm128"
|
||||
nonceinfo = b"Content-Encoding: nonce"
|
||||
elif version == "aes128gcm":
|
||||
keyinfo = b"Content-Encoding: aes128gcm\x00"
|
||||
nonceinfo = b"Content-Encoding: nonce\x00"
|
||||
if dh is None:
|
||||
# Only mix the authentication secret when using DH for aes128gcm
|
||||
auth_secret = None
|
||||
|
||||
if auth_secret is not None:
|
||||
if version == "aes128gcm":
|
||||
info = context
|
||||
else:
|
||||
info = build_info(b"auth", b"")
|
||||
hkdf_auth = HKDF(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=32,
|
||||
salt=auth_secret,
|
||||
info=info,
|
||||
backend=default_backend(),
|
||||
)
|
||||
secret = hkdf_auth.derive(secret)
|
||||
|
||||
hkdf_key = HKDF(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=KEY_LENGTH,
|
||||
salt=salt,
|
||||
info=keyinfo,
|
||||
backend=default_backend(),
|
||||
)
|
||||
hkdf_nonce = HKDF(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=NONCE_LENGTH,
|
||||
salt=salt,
|
||||
info=nonceinfo,
|
||||
backend=default_backend(),
|
||||
)
|
||||
return hkdf_key.derive(secret), hkdf_nonce.derive(secret)
|
||||
|
||||
|
||||
def iv(base, counter):
|
||||
"""Generate an initialization vector."""
|
||||
if (counter >> 64) != 0:
|
||||
raise ECEException("Counter too big")
|
||||
(mask,) = struct.unpack("!Q", base[4:])
|
||||
return base[:4] + struct.pack("!Q", counter ^ mask)
|
||||
|
||||
|
||||
def decrypt(
|
||||
content,
|
||||
salt=None,
|
||||
key=None,
|
||||
private_key=None,
|
||||
dh=None,
|
||||
auth_secret=None,
|
||||
keyid=None,
|
||||
keylabel="P-256",
|
||||
rs=4096,
|
||||
version="aes128gcm",
|
||||
):
|
||||
"""
|
||||
Decrypt a data block
|
||||
|
||||
:param content: Data to be decrypted
|
||||
:type content: str
|
||||
:param salt: Encryption salt
|
||||
:type salt: str
|
||||
:param key: local public key
|
||||
:type key: str
|
||||
:param private_key: DH private key
|
||||
:type key: object
|
||||
:param keyid: Internal key identifier for private key info
|
||||
:type keyid: str
|
||||
:param dh: Remote Diffie Hellman sequence (omit for aes128gcm)
|
||||
:type dh: str
|
||||
:param rs: Record size
|
||||
:type rs: int
|
||||
:param auth_secret: Authorization secret
|
||||
:type auth_secret: str
|
||||
:param version: ECE Method version
|
||||
:type version: enumerate('aes128gcm', 'aesgcm', 'aesgcm128')
|
||||
:return: Decrypted message content
|
||||
:rtype str
|
||||
|
||||
"""
|
||||
|
||||
def parse_content_header(content):
|
||||
"""Parse an aes128gcm content body and extract the header values.
|
||||
|
||||
:param content: The encrypted body of the message
|
||||
:type content: str
|
||||
|
||||
"""
|
||||
id_len = struct.unpack("!B", content[20:21])[0]
|
||||
return {
|
||||
"salt": content[:16],
|
||||
"rs": struct.unpack("!L", content[16:20])[0],
|
||||
"keyid": content[21 : 21 + id_len],
|
||||
"content": content[21 + id_len :],
|
||||
}
|
||||
|
||||
def decrypt_record(key, nonce, counter, content):
|
||||
decryptor = Cipher(
|
||||
algorithms.AES(key),
|
||||
modes.GCM(iv(nonce, counter), tag=content[-TAG_LENGTH:]),
|
||||
backend=default_backend(),
|
||||
).decryptor()
|
||||
return decryptor.update(content[:-TAG_LENGTH]) + decryptor.finalize()
|
||||
|
||||
def unpad_legacy(data):
|
||||
pad_size = versions[version]["pad"]
|
||||
pad = functools.reduce(
|
||||
lambda x, y: x << 8 | y,
|
||||
struct.unpack("!" + ("B" * pad_size), data[0:pad_size]),
|
||||
)
|
||||
if pad_size + pad > len(data) or data[pad_size : pad_size + pad] != (
|
||||
b"\x00" * pad
|
||||
):
|
||||
raise ECEException("Bad padding")
|
||||
return data[pad_size + pad :]
|
||||
|
||||
def unpad(data, last):
|
||||
i = len(data) - 1
|
||||
for i in range(len(data) - 1, -1, -1):
|
||||
v = struct.unpack("B", data[i : i + 1])[0]
|
||||
if v != 0:
|
||||
if not last and v != 1:
|
||||
raise ECEException("record delimiter != 1")
|
||||
if last and v != 2:
|
||||
raise ECEException("last record delimiter != 2")
|
||||
return data[0:i]
|
||||
raise ECEException("all zero record plaintext")
|
||||
|
||||
if version not in versions:
|
||||
raise ECEException("Invalid version")
|
||||
|
||||
overhead = versions[version]["pad"]
|
||||
if version == "aes128gcm":
|
||||
try:
|
||||
content_header = parse_content_header(content)
|
||||
except Exception:
|
||||
raise ECEException("Could not parse the content header")
|
||||
salt = content_header["salt"]
|
||||
rs = content_header["rs"]
|
||||
keyid = content_header["keyid"]
|
||||
if private_key is not None and not dh:
|
||||
dh = keyid
|
||||
else:
|
||||
keyid = keyid.decode("utf-8")
|
||||
content = content_header["content"]
|
||||
overhead += 16
|
||||
|
||||
(key_, nonce_) = derive_key(
|
||||
"decrypt",
|
||||
version=version,
|
||||
salt=salt,
|
||||
key=key,
|
||||
private_key=private_key,
|
||||
dh=dh,
|
||||
auth_secret=auth_secret,
|
||||
keyid=keyid,
|
||||
keylabel=keylabel,
|
||||
)
|
||||
if rs <= overhead:
|
||||
raise ECEException("Record size too small")
|
||||
chunk = rs
|
||||
if version != "aes128gcm":
|
||||
chunk += 16 # account for tags in old versions
|
||||
if len(content) % chunk == 0:
|
||||
raise ECEException("Message truncated")
|
||||
|
||||
result = b""
|
||||
counter = 0
|
||||
try:
|
||||
for i in list(range(0, len(content), chunk)):
|
||||
data = decrypt_record(key_, nonce_, counter, content[i : i + chunk])
|
||||
if version == "aes128gcm":
|
||||
last = (i + chunk) >= len(content)
|
||||
result += unpad(data, last)
|
||||
else:
|
||||
result += unpad_legacy(data)
|
||||
counter += 1
|
||||
except InvalidTag as ex:
|
||||
raise ECEException("Decryption error: {}".format(repr(ex)))
|
||||
return result
|
||||
|
||||
|
||||
def encrypt(
|
||||
content,
|
||||
salt=None,
|
||||
key=None,
|
||||
private_key=None,
|
||||
dh=None,
|
||||
auth_secret=None,
|
||||
keyid=None,
|
||||
keylabel="P-256",
|
||||
rs=4096,
|
||||
version="aes128gcm",
|
||||
):
|
||||
"""
|
||||
Encrypt a data block
|
||||
|
||||
:param content: block of data to encrypt
|
||||
:type content: str
|
||||
:param salt: Encryption salt
|
||||
:type salt: str
|
||||
:param key: Encryption key data
|
||||
:type key: str
|
||||
:param private_key: DH private key
|
||||
:type key: object
|
||||
:param keyid: Internal key identifier for private key info
|
||||
:type keyid: str
|
||||
:param dh: Remote Diffie Hellman sequence
|
||||
:type dh: str
|
||||
:param rs: Record size
|
||||
:type rs: int
|
||||
:param auth_secret: Authorization secret
|
||||
:type auth_secret: str
|
||||
:param version: ECE Method version
|
||||
:type version: enumerate('aes128gcm', 'aesgcm', 'aesgcm128')
|
||||
:return: Encrypted message content
|
||||
:rtype str
|
||||
|
||||
"""
|
||||
|
||||
def encrypt_record(key, nonce, counter, buf, last):
|
||||
encryptor = Cipher(
|
||||
algorithms.AES(key),
|
||||
modes.GCM(iv(nonce, counter)),
|
||||
backend=default_backend(),
|
||||
).encryptor()
|
||||
|
||||
if version == "aes128gcm":
|
||||
data = encryptor.update(buf + (b"\x02" if last else b"\x01"))
|
||||
else:
|
||||
data = encryptor.update((b"\x00" * versions[version]["pad"]) + buf)
|
||||
data += encryptor.finalize()
|
||||
data += encryptor.tag
|
||||
return data
|
||||
|
||||
def compose_aes128gcm(salt, content, rs, keyid):
|
||||
"""Compose the header and content of an aes128gcm encrypted
|
||||
message body
|
||||
|
||||
:param salt: The sender's salt value
|
||||
:type salt: str
|
||||
:param content: The encrypted body of the message
|
||||
:type content: str
|
||||
:param rs: Override for the content length
|
||||
:type rs: int
|
||||
:param keyid: The keyid to use for this message
|
||||
:type keyid: str
|
||||
|
||||
"""
|
||||
if len(keyid) > 255:
|
||||
raise ECEException("keyid is too long")
|
||||
header = salt
|
||||
if rs > MAX_RECORD_SIZE:
|
||||
raise ECEException("Too much content")
|
||||
header += struct.pack("!L", rs)
|
||||
header += struct.pack("!B", len(keyid))
|
||||
header += keyid
|
||||
return header + content
|
||||
|
||||
if version not in versions:
|
||||
raise ECEException("Invalid version")
|
||||
|
||||
if salt is None:
|
||||
salt = os.urandom(16)
|
||||
|
||||
(key_, nonce_) = derive_key(
|
||||
"encrypt",
|
||||
version=version,
|
||||
salt=salt,
|
||||
key=key,
|
||||
private_key=private_key,
|
||||
dh=dh,
|
||||
auth_secret=auth_secret,
|
||||
keyid=keyid,
|
||||
keylabel=keylabel,
|
||||
)
|
||||
|
||||
overhead = versions[version]["pad"]
|
||||
if version == "aes128gcm":
|
||||
overhead += 16
|
||||
end = len(content)
|
||||
else:
|
||||
end = len(content) + 1
|
||||
if rs <= overhead:
|
||||
raise ECEException("Record size too small")
|
||||
chunk_size = rs - overhead
|
||||
|
||||
result = b""
|
||||
counter = 0
|
||||
|
||||
# the extra one on the loop ensures that we produce a padding only
|
||||
# record if the data length is an exact multiple of the chunk size
|
||||
for i in list(range(0, end, chunk_size)):
|
||||
result += encrypt_record(
|
||||
key_, nonce_, counter, content[i : i + chunk_size], (i + chunk_size) >= end
|
||||
)
|
||||
counter += 1
|
||||
if version == "aes128gcm":
|
||||
if keyid is None and private_key is not None:
|
||||
kid = private_key.public_key().public_bytes(
|
||||
Encoding.X962, PublicFormat.UncompressedPoint
|
||||
)
|
||||
else:
|
||||
kid = (keyid or "").encode("utf-8")
|
||||
return compose_aes128gcm(salt, result, rs, keyid=kid)
|
||||
return result
|
||||
@@ -0,0 +1,496 @@
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import unittest
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
||||
|
||||
from pytest import raises
|
||||
|
||||
import http_ece as ece
|
||||
from http_ece import ECEException
|
||||
|
||||
|
||||
TEST_VECTORS = os.path.join(os.sep, "..", "encrypt_data.json")[1:]
|
||||
|
||||
|
||||
def logmsg(arg):
|
||||
"""
|
||||
print(arg)
|
||||
"""
|
||||
return
|
||||
|
||||
|
||||
def logbuf(msg, buf):
|
||||
"""used for debugging test code."""
|
||||
if buf is None:
|
||||
buf = b""
|
||||
logmsg(msg + ": [" + str(len(buf)) + "]")
|
||||
for i in list(range(0, len(buf), 48)):
|
||||
logmsg(" " + repr(buf[i : i + 48]))
|
||||
return
|
||||
|
||||
|
||||
def b64e(arg):
|
||||
if arg is None:
|
||||
return None
|
||||
return base64.urlsafe_b64encode(arg).decode()
|
||||
|
||||
|
||||
def b64d(arg):
|
||||
if arg is None:
|
||||
return None
|
||||
return base64.urlsafe_b64decode(str(arg) + "===="[: len(arg) % 4 :])
|
||||
|
||||
|
||||
def make_key():
|
||||
return ec.generate_private_key(ec.SECP256R1(), default_backend())
|
||||
|
||||
|
||||
class TestEce(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.private_key = make_key()
|
||||
self.dh = self.private_key.public_key().public_bytes(
|
||||
Encoding.X962, PublicFormat.UncompressedPoint
|
||||
)
|
||||
self.m_key = os.urandom(16)
|
||||
self.m_salt = os.urandom(16)
|
||||
|
||||
def test_derive_key_invalid_mode(self):
|
||||
with raises(ECEException) as ex:
|
||||
ece.derive_key(
|
||||
"invalid",
|
||||
version="aes128gcm",
|
||||
salt=self.m_salt,
|
||||
key=self.m_key,
|
||||
private_key=self.private_key,
|
||||
dh=None,
|
||||
auth_secret=None,
|
||||
keyid="valid",
|
||||
)
|
||||
assert ex.value.message == "unknown 'mode' specified: invalid"
|
||||
|
||||
def test_derive_key_invalid_salt(self):
|
||||
with raises(ECEException) as ex:
|
||||
ece.derive_key(
|
||||
"encrypt",
|
||||
version="aes128gcm",
|
||||
salt=None,
|
||||
key=self.m_key,
|
||||
private_key=self.private_key,
|
||||
dh=None,
|
||||
auth_secret=None,
|
||||
keyid="valid",
|
||||
)
|
||||
assert ex.value.message == "'salt' must be a 16 octet value"
|
||||
|
||||
def test_derive_key_invalid_version(self):
|
||||
with raises(ECEException) as ex:
|
||||
ece.derive_key(
|
||||
"encrypt",
|
||||
version="invalid",
|
||||
salt=self.m_salt,
|
||||
key=None,
|
||||
private_key=self.private_key,
|
||||
dh=None,
|
||||
auth_secret=None,
|
||||
keyid="valid",
|
||||
)
|
||||
assert ex.value.message == "Invalid version"
|
||||
|
||||
def test_derive_key_no_private_key(self):
|
||||
with raises(ECEException) as ex:
|
||||
ece.derive_key(
|
||||
"encrypt",
|
||||
version="aes128gcm",
|
||||
salt=self.m_salt,
|
||||
key=None,
|
||||
private_key=None,
|
||||
dh=self.dh,
|
||||
auth_secret=None,
|
||||
keyid="valid",
|
||||
)
|
||||
assert ex.value.message == "DH requires a private_key"
|
||||
|
||||
def test_derive_key_no_secret(self):
|
||||
with raises(ECEException) as ex:
|
||||
ece.derive_key(
|
||||
"encrypt",
|
||||
version="aes128gcm",
|
||||
salt=self.m_salt,
|
||||
key=None,
|
||||
private_key=None,
|
||||
dh=None,
|
||||
auth_secret=None,
|
||||
keyid="valid",
|
||||
)
|
||||
assert ex.value.message == "unable to determine the secret"
|
||||
|
||||
def test_iv_bad_counter(self):
|
||||
with raises(ECEException) as ex:
|
||||
ece.iv(os.urandom(8), pow(2, 64) + 1)
|
||||
assert ex.value.message == "Counter too big"
|
||||
|
||||
|
||||
class TestEceChecking(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.m_key = os.urandom(16)
|
||||
self.m_input = os.urandom(5)
|
||||
# This header is specific to the padding tests, but can be used
|
||||
# elsewhere
|
||||
self.m_header = b"\xaa\xd2\x05}3S\xb7\xff7\xbd\xe4*\xe1\xd5\x0f\xda"
|
||||
self.m_header += struct.pack("!L", 32) + b"\0"
|
||||
|
||||
def test_encrypt_small_rs(self):
|
||||
with raises(ECEException) as ex:
|
||||
ece.encrypt(
|
||||
self.m_input,
|
||||
version="aes128gcm",
|
||||
key=self.m_key,
|
||||
rs=1,
|
||||
)
|
||||
assert ex.value.message == "Record size too small"
|
||||
|
||||
def test_decrypt_small_rs(self):
|
||||
header = os.urandom(16) + struct.pack("!L", 2) + b"\0"
|
||||
with raises(ECEException) as ex:
|
||||
ece.decrypt(
|
||||
header + self.m_input,
|
||||
version="aes128gcm",
|
||||
key=self.m_key,
|
||||
rs=1,
|
||||
)
|
||||
assert ex.value.message == "Record size too small"
|
||||
|
||||
def test_encrypt_bad_version(self):
|
||||
with raises(ECEException) as ex:
|
||||
ece.encrypt(
|
||||
self.m_input,
|
||||
version="bogus",
|
||||
key=self.m_key,
|
||||
)
|
||||
assert ex.value.message == "Invalid version"
|
||||
|
||||
def test_decrypt_bad_version(self):
|
||||
with raises(ECEException) as ex:
|
||||
ece.decrypt(
|
||||
self.m_input,
|
||||
version="bogus",
|
||||
key=self.m_key,
|
||||
)
|
||||
assert ex.value.message == "Invalid version"
|
||||
|
||||
def test_decrypt_bad_header(self):
|
||||
with raises(ECEException) as ex:
|
||||
ece.decrypt(
|
||||
os.urandom(4),
|
||||
version="aes128gcm",
|
||||
key=self.m_key,
|
||||
)
|
||||
assert ex.value.message == "Could not parse the content header"
|
||||
|
||||
def test_encrypt_long_keyid(self):
|
||||
with raises(ECEException) as ex:
|
||||
ece.encrypt(
|
||||
self.m_input,
|
||||
version="aes128gcm",
|
||||
key=self.m_key,
|
||||
keyid=b64e(os.urandom(192)), # 256 bytes
|
||||
)
|
||||
assert ex.value.message == "keyid is too long"
|
||||
|
||||
def test_overlong_padding(self):
|
||||
with raises(ECEException) as ex:
|
||||
ece.decrypt(
|
||||
self.m_header + b"\xbb\xc7\xb9ev\x0b\xf0f+\x93\xf4"
|
||||
b"\xe5\xd6\x94\xb7e\xf0\xcd\x15\x9b(\x01\xa5",
|
||||
version="aes128gcm",
|
||||
key=b"d\xc7\x0ed\xa7%U\x14Q\xf2\x08\xdf\xba\xa0\xb9r",
|
||||
keyid=b64e(os.urandom(192)), # 256 bytes
|
||||
)
|
||||
assert ex.value.message == "all zero record plaintext"
|
||||
|
||||
def test_bad_early_delimiter(self):
|
||||
with raises(ECEException) as ex:
|
||||
ece.decrypt(
|
||||
self.m_header + b"\xb9\xc7\xb9ev\x0b\xf0\x9eB\xb1\x08C8u"
|
||||
b"\xa3\x06\xc9x\x06\n\xfc|}\xe9R\x85\x91"
|
||||
b"\x8bX\x02`\xf3"
|
||||
+ b"E8z(\xe5%f/H\xc1\xc32\x04\xb1\x95\xb5N\x9ep\xd4\x0e<\xf3"
|
||||
b"\xef\x0cg\x1b\xe0\x14I~\xdc",
|
||||
version="aes128gcm",
|
||||
key=b"d\xc7\x0ed\xa7%U\x14Q\xf2\x08\xdf\xba\xa0\xb9r",
|
||||
keyid=b64e(os.urandom(192)), # 256 bytes
|
||||
)
|
||||
assert ex.value.message == "record delimiter != 1"
|
||||
|
||||
def test_bad_final_delimiter(self):
|
||||
with raises(ECEException) as ex:
|
||||
ece.decrypt(
|
||||
self.m_header + b"\xba\xc7\xb9ev\x0b\xf0\x9eB\xb1\x08Ji"
|
||||
b"\xe4P\x1b\x8dI\xdb\xc6y#MG\xc2W\x16",
|
||||
version="aes128gcm",
|
||||
key=b"d\xc7\x0ed\xa7%U\x14Q\xf2\x08\xdf\xba\xa0\xb9r",
|
||||
keyid=b64e(os.urandom(192)), # 256 bytes
|
||||
)
|
||||
assert ex.value.message == "last record delimiter != 2"
|
||||
|
||||
def test_damage(self):
|
||||
with raises(ECEException) as ex:
|
||||
ece.decrypt(
|
||||
self.m_header + b"\xbb\xc6\xb1\x1dF:~\x0f\x07+\xbe\xaaD"
|
||||
b"\xe0\xd6.K\xe5\xf9]%\xe3\x86q\xe0}",
|
||||
version="aes128gcm",
|
||||
key=b"d\xc7\x0ed\xa7%U\x14Q\xf2\x08\xdf\xba\xa0\xb9r",
|
||||
keyid=b64e(os.urandom(192)), # 256 bytes
|
||||
)
|
||||
assert ex.value.message == "Decryption error: InvalidTag()"
|
||||
|
||||
|
||||
class TestEceIntegration(unittest.TestCase):
|
||||
def setUp(self):
|
||||
ece.keys = {}
|
||||
ece.labels = {}
|
||||
|
||||
def tearDown(self):
|
||||
ece.keys = {}
|
||||
ece.labels = {}
|
||||
|
||||
def _rsoverhead(self, version):
|
||||
if version == "aesgcm128":
|
||||
return 1
|
||||
if version == "aesgcm":
|
||||
return 2
|
||||
return 18
|
||||
|
||||
def _generate_input(self, minLen=0):
|
||||
length = struct.unpack("!B", os.urandom(1))[0] + minLen
|
||||
return os.urandom(length)
|
||||
|
||||
def encrypt_decrypt(self, input, encrypt_params, decrypt_params=None, version=None):
|
||||
"""Run and encrypt/decrypt cycle on some test data
|
||||
|
||||
:param input: data for input
|
||||
:type length: bytearray
|
||||
:param encrypt_params: Dictionary of encryption parameters
|
||||
:type encrypt_params: dict
|
||||
:param decrypt_params: Optional dictionary of decryption parameters
|
||||
:type decrypt_params: dict
|
||||
:param version: Content-Type of the body, formulating encryption
|
||||
:type enumerate("aes128gcm", "aesgcm", "aesgcm128"):
|
||||
"""
|
||||
if decrypt_params is None:
|
||||
decrypt_params = encrypt_params
|
||||
logbuf("Input", input)
|
||||
if "key" in encrypt_params:
|
||||
logbuf("Key", encrypt_params["key"])
|
||||
if version != "aes128gcm":
|
||||
salt = os.urandom(16)
|
||||
decrypt_rs_default = 4096
|
||||
else:
|
||||
salt = None
|
||||
decrypt_rs_default = None
|
||||
logbuf("Salt", salt)
|
||||
if "auth_secret" in encrypt_params:
|
||||
logbuf("Auth Secret", encrypt_params["auth_secret"])
|
||||
encrypted = ece.encrypt(
|
||||
input,
|
||||
salt=salt,
|
||||
key=encrypt_params.get("key"),
|
||||
keyid=encrypt_params.get("keyid"),
|
||||
dh=encrypt_params.get("dh"),
|
||||
private_key=encrypt_params.get("private_key"),
|
||||
auth_secret=encrypt_params.get("auth_secret"),
|
||||
rs=encrypt_params.get("rs", 4096),
|
||||
version=version,
|
||||
)
|
||||
logbuf("Encrypted", encrypted)
|
||||
decrypted = ece.decrypt(
|
||||
encrypted,
|
||||
salt=salt,
|
||||
key=decrypt_params.get("key"),
|
||||
keyid=decrypt_params.get("keyid"),
|
||||
dh=decrypt_params.get("dh"),
|
||||
private_key=decrypt_params.get("private_key"),
|
||||
auth_secret=decrypt_params.get("auth_secret"),
|
||||
rs=decrypt_params.get("rs", decrypt_rs_default),
|
||||
version=version,
|
||||
)
|
||||
logbuf("Decrypted", decrypted)
|
||||
assert input == decrypted
|
||||
|
||||
def use_explicit_key(self, version=None):
|
||||
params = {
|
||||
"key": os.urandom(16),
|
||||
}
|
||||
self.encrypt_decrypt(self._generate_input(), params, version=version)
|
||||
|
||||
def auth_secret(self, version):
|
||||
params = {"key": os.urandom(16), "auth_secret": os.urandom(16)}
|
||||
self.encrypt_decrypt(self._generate_input(), params, version=version)
|
||||
|
||||
def exactly_one_record(self, version=None):
|
||||
input = self._generate_input(1)
|
||||
params = {"key": os.urandom(16), "rs": len(input) + self._rsoverhead(version)}
|
||||
self.encrypt_decrypt(input, params, version=version)
|
||||
|
||||
def detect_truncation(self, version):
|
||||
if version == "aes128gcm":
|
||||
return
|
||||
|
||||
input = self._generate_input(2)
|
||||
key = os.urandom(16)
|
||||
salt = os.urandom(16)
|
||||
|
||||
rs = len(input) + self._rsoverhead(version) - 1
|
||||
encrypted = ece.encrypt(input, salt=salt, key=key, rs=rs, version=version)
|
||||
if version == "aes128gcm":
|
||||
chunk = encrypted[0 : 21 + rs]
|
||||
else:
|
||||
chunk = encrypted[0 : rs + 16]
|
||||
with raises(ECEException) as ex:
|
||||
ece.decrypt(chunk, salt=salt, key=key, rs=rs, version=version)
|
||||
assert ex.value.message == "Message truncated"
|
||||
|
||||
def use_dh(self, version):
|
||||
def pubbytes(k):
|
||||
return k.public_key().public_bytes(
|
||||
Encoding.X962, PublicFormat.UncompressedPoint
|
||||
)
|
||||
|
||||
def privbytes(k):
|
||||
d = k.private_numbers().private_value
|
||||
b = b""
|
||||
for i in range(0, k.private_numbers().public_numbers.curve.key_size, 32):
|
||||
b = struct.pack("!L", (d >> i) & 0xFFFFFFFF) + b
|
||||
return b
|
||||
|
||||
def logec(s, k):
|
||||
logbuf(s + " private", privbytes(k))
|
||||
logbuf(s + " public", pubbytes(k))
|
||||
|
||||
def is_uncompressed(k):
|
||||
b1 = pubbytes(k)[0:1]
|
||||
assert struct.unpack("B", b1)[0] == 4, "is an uncompressed point"
|
||||
|
||||
# the static key is used by the receiver
|
||||
static_key = make_key()
|
||||
is_uncompressed(static_key)
|
||||
|
||||
logec("receiver", static_key)
|
||||
|
||||
# the ephemeral key is used by the sender
|
||||
ephemeral_key = make_key()
|
||||
is_uncompressed(ephemeral_key)
|
||||
|
||||
logec("sender", ephemeral_key)
|
||||
|
||||
auth_secret = os.urandom(16)
|
||||
|
||||
if version != "aes128gcm":
|
||||
decrypt_dh = pubbytes(ephemeral_key)
|
||||
else:
|
||||
decrypt_dh = None
|
||||
|
||||
encrypt_params = {
|
||||
"private_key": ephemeral_key,
|
||||
"dh": static_key.public_key(),
|
||||
"auth_secret": auth_secret,
|
||||
}
|
||||
decrypt_params = {
|
||||
"private_key": static_key,
|
||||
"dh": decrypt_dh,
|
||||
"auth_secret": auth_secret,
|
||||
}
|
||||
|
||||
self.encrypt_decrypt(
|
||||
self._generate_input(), encrypt_params, decrypt_params, version
|
||||
)
|
||||
|
||||
def test_types(self):
|
||||
for ver in ["aes128gcm", "aesgcm", "aesgcm128"]:
|
||||
for f in (
|
||||
self.use_dh,
|
||||
self.use_explicit_key,
|
||||
self.auth_secret,
|
||||
self.exactly_one_record,
|
||||
self.detect_truncation,
|
||||
):
|
||||
ece.keys = {}
|
||||
ece.labels = {}
|
||||
f(version=ver)
|
||||
|
||||
|
||||
class TestNode(unittest.TestCase):
|
||||
"""Testing using data from the node.js version."""
|
||||
|
||||
def setUp(self):
|
||||
if not os.path.exists(TEST_VECTORS):
|
||||
self.skipTest("No %s file found" % TEST_VECTORS)
|
||||
f = open(TEST_VECTORS, "r")
|
||||
self.legacy_data = json.loads(f.read())
|
||||
f.close()
|
||||
|
||||
def _run(self, mode):
|
||||
if mode == "encrypt":
|
||||
func = ece.encrypt
|
||||
local = "sender"
|
||||
inp = "input"
|
||||
outp = "encrypted"
|
||||
else:
|
||||
func = ece.decrypt
|
||||
local = "receiver"
|
||||
inp = "encrypted"
|
||||
outp = "input"
|
||||
|
||||
for data in self.legacy_data:
|
||||
logmsg("%s: %s" % (mode, data["test"]))
|
||||
p = data["params"][mode]
|
||||
|
||||
if "pad" in p and mode == "encrypt":
|
||||
# This library doesn't pad in exactly the same way.
|
||||
continue
|
||||
|
||||
if "keys" in data:
|
||||
key = None
|
||||
decode_pub = ec.EllipticCurvePublicNumbers.from_encoded_point
|
||||
pubnum = decode_pub(ec.SECP256R1(), b64d(data["keys"][local]["public"]))
|
||||
d = 0
|
||||
dbin = b64d(data["keys"][local]["private"])
|
||||
for i in range(0, len(dbin), 4):
|
||||
d = (d << 32) + struct.unpack("!L", dbin[i : i + 4])[0]
|
||||
privnum = ec.EllipticCurvePrivateNumbers(d, pubnum)
|
||||
private_key = privnum.private_key(default_backend())
|
||||
else:
|
||||
key = b64d(p["key"])
|
||||
private_key = None
|
||||
|
||||
if "authSecret" in p:
|
||||
auth_secret = b64d(p["authSecret"])
|
||||
else:
|
||||
auth_secret = None
|
||||
if "dh" in p:
|
||||
dh = b64d(p["dh"])
|
||||
else:
|
||||
dh = None
|
||||
|
||||
result = func(
|
||||
b64d(data[inp]),
|
||||
salt=b64d(p["salt"]),
|
||||
key=key,
|
||||
dh=dh,
|
||||
auth_secret=auth_secret,
|
||||
keyid=p.get("keyid"),
|
||||
private_key=private_key,
|
||||
rs=p.get("rs", 4096),
|
||||
version=p["version"],
|
||||
)
|
||||
assert b64d(data[outp]) == result
|
||||
|
||||
def test_decrypt(self):
|
||||
self._run("decrypt")
|
||||
|
||||
def test_encrypt(self):
|
||||
self._run("encrypt")
|
||||
@@ -0,0 +1,127 @@
|
||||
Metadata-Version: 2.4
|
||||
Name: py-vapid
|
||||
Version: 1.9.4
|
||||
Summary: Simple VAPID header generation library
|
||||
Project-URL: Homepage, https://github.com/mozilla-services/vapid
|
||||
Author-email: JR Conlin <src+vapid@jrconlin.com>
|
||||
License: MPL-2.0
|
||||
License-File: LICENSE
|
||||
Keywords: push,vapid,webpush
|
||||
Classifier: Programming Language :: Python
|
||||
Classifier: Programming Language :: Python :: 3
|
||||
Classifier: Topic :: Internet :: WWW/HTTP
|
||||
Requires-Dist: cryptography>=46
|
||||
Provides-Extra: test
|
||||
Requires-Dist: coverage; extra == 'test'
|
||||
Requires-Dist: flake8; extra == 'test'
|
||||
Requires-Dist: mock>=1.0; extra == 'test'
|
||||
Requires-Dist: pytest; extra == 'test'
|
||||
Description-Content-Type: text/x-rst
|
||||
|
||||
|PyPI version py_vapid|
|
||||
|
||||
Easy VAPID generation
|
||||
=====================
|
||||
|
||||
This minimal library contains the minimal set of functions you need to
|
||||
generate a VAPID key set and get the headers you’ll need to sign a
|
||||
WebPush subscription update.
|
||||
|
||||
VAPID is a voluntary standard for WebPush subscription providers (sites
|
||||
that send WebPush updates to remote customers) to self-identify to Push
|
||||
Servers (the servers that convey the push notifications).
|
||||
|
||||
The VAPID “claims” are a set of JSON keys and values. There are two
|
||||
required fields, one semi-optional and several optional additional
|
||||
fields.
|
||||
|
||||
At a minimum a VAPID claim set should look like:
|
||||
|
||||
::
|
||||
|
||||
{"sub":"mailto:YourEmail@YourSite.com","aud":"https://PushServer","exp":"ExpirationTimestamp"}
|
||||
|
||||
A few notes:
|
||||
|
||||
**sub** is the email address you wish to have on record for this
|
||||
request, prefixed with “``mailto:``”. If things go wrong, this is the
|
||||
email that will be used to contact you (for instance). This can be a
|
||||
general delivery address like “``mailto:push_operations@example.com``”
|
||||
or a specific address like “``mailto:bob@example.com``”.
|
||||
|
||||
**aud** is the audience for the VAPID. This is the scheme and host you
|
||||
use to send subscription endpoints and generally coincides with the
|
||||
``endpoint`` specified in the Subscription Info block.
|
||||
|
||||
As example, if a WebPush subscription info contains:
|
||||
``{"endpoint": "https://push.example.com:8012/v1/push/...", ...}``
|
||||
|
||||
then the ``aud`` would be “``https://push.example.com:8012``”
|
||||
|
||||
While some Push Services consider this an optional field, others may be
|
||||
stricter.
|
||||
|
||||
**exp** This is the UTC timestamp for when this VAPID request will
|
||||
expire. The maximum period is 24 hours. Setting a shorter period can
|
||||
prevent “replay” attacks. Setting a longer period allows you to reuse
|
||||
headers for multiple sends (e.g. if you’re sending hundreds of updates
|
||||
within an hour or so.) If no ``exp`` is included, one that will expire
|
||||
in 24 hours will be auto-generated for you.
|
||||
|
||||
Claims should be stored in a JSON compatible file. In the examples
|
||||
below, we’ve stored the claims into a file named ``claims.json``.
|
||||
|
||||
py_vapid can either be installed as a library or used as a stand along
|
||||
app, ``bin/vapid``.
|
||||
|
||||
App Installation
|
||||
----------------
|
||||
|
||||
You’ll need ``python virtualenv`` Run that in the current directory.
|
||||
|
||||
Then run
|
||||
|
||||
::
|
||||
|
||||
bin/pip install -r requirements.txt
|
||||
|
||||
bin/python -m pip install -e .
|
||||
|
||||
App Usage
|
||||
---------
|
||||
|
||||
Run by itself, ``bin/vapid`` will check and optionally create the
|
||||
public_key.pem and private_key.pem files.
|
||||
|
||||
``bin/vapid --gen`` can be used to generate a new set of public and
|
||||
private key PEM files. These will overwrite the contents of
|
||||
``private_key.pem`` and ``public_key.pem``.
|
||||
|
||||
``bin/vapid --sign claims.json`` will generate a set of HTTP headers
|
||||
from a JSON formatted claims file. A sample ``claims.json`` is included
|
||||
with this distribution.
|
||||
|
||||
``bin/vapid --sign claims.json --json`` will output the headers in JSON
|
||||
format, which may be useful for other programs.
|
||||
|
||||
``bin/vapid --applicationServerKey`` will return the
|
||||
``applicationServerKey`` value you can use to make a restricted
|
||||
endpoint. See
|
||||
https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe
|
||||
for more details. Be aware that this value is tied to the generated
|
||||
public/private key. If you remove or generate a new key, any restricted
|
||||
URL you’ve previously generated will need to be reallocated. Please note
|
||||
that some User Agents may require you `to decode this string into a
|
||||
Uint8Array <https://github.com/GoogleChrome/push-notifications/blob/master/app/scripts/main.js>`__.
|
||||
|
||||
See ``bin/vapid -h`` for all options and commands.
|
||||
|
||||
CHANGELOG
|
||||
---------
|
||||
|
||||
I’m terrible about updating the Changelog. Please see the
|
||||
```git log`` <https://github.com/web-push-libs/vapid/pulls?q=is%3Apr+is%3Aclosed>`__
|
||||
history for details.
|
||||
|
||||
.. |PyPI version py_vapid| image:: https://badge.fury.io/py/py-vapid.svg
|
||||
:target: https://pypi.org/project/py-vapid/
|
||||
@@ -0,0 +1,12 @@
|
||||
py_vapid/__init__.py,sha256=mw3C-f44oNgyrdtBjiQfVQWajblAn9PzdNXkgHLmq_c,12572
|
||||
py_vapid/__main__.py,sha256=SUF8l6XUGRXf5f3t3SE6gIifpa2Wml4z9XstEaUowGU,4657
|
||||
py_vapid/jwt.py,sha256=GIbrJc2Sb2frpZQMbCY04Cw9v-iHolBccy5qKCrDYE8,2547
|
||||
py_vapid/main.py,sha256=44Lyn5lDapqQ8OCMxzcwlMZsm9jWh_zEkQcfzeteCno,4548
|
||||
py_vapid/utils.py,sha256=1OGZIKOcQRnjJaTzy81SVopIyvC-AGME-eu5nBcnPLw,921
|
||||
py_vapid/tests/.test_vapid.py.swp,sha256=F47Jt7zk85_Pv46wQgerqo7DuJHrHe99dd2B6TxcCS0,16384
|
||||
py_vapid/tests/test_vapid.py,sha256=Rg1bBdhUUhF9sgRuwcDcJd0wGLTff7HmyOBifGtBOJc,10403
|
||||
py_vapid-1.9.4.dist-info/METADATA,sha256=_iiqfj5sVbSZQKt7EeQ-miKfjOAfzBvCkjkf6wPXqkU,4596
|
||||
py_vapid-1.9.4.dist-info/WHEEL,sha256=aha0VrrYvgDJ3Xxl3db_g_MDIW-ZexDdrc_m-Hk8YY4,105
|
||||
py_vapid-1.9.4.dist-info/entry_points.txt,sha256=8VfF1HHZcNIS15s9Y9JNV3Ue2nX2VIbSbOdkxClZJRc,45
|
||||
py_vapid-1.9.4.dist-info/licenses/LICENSE,sha256=HyVuytGSiAUQ6ErWBHTqt1iSGHhLmlC8fO7jTCuR8dU,16725
|
||||
py_vapid-1.9.4.dist-info/RECORD,,
|
||||
@@ -0,0 +1,5 @@
|
||||
Wheel-Version: 1.0
|
||||
Generator: hatchling 1.28.0
|
||||
Root-Is-Purelib: true
|
||||
Tag: py2-none-any
|
||||
Tag: py3-none-any
|
||||
@@ -0,0 +1,2 @@
|
||||
[console_scripts]
|
||||
vapid = py_vapid.main:main
|
||||
@@ -0,0 +1,373 @@
|
||||
Mozilla Public License Version 2.0
|
||||
==================================
|
||||
|
||||
1. Definitions
|
||||
--------------
|
||||
|
||||
1.1. "Contributor"
|
||||
means each individual or legal entity that creates, contributes to
|
||||
the creation of, or owns Covered Software.
|
||||
|
||||
1.2. "Contributor Version"
|
||||
means the combination of the Contributions of others (if any) used
|
||||
by a Contributor and that particular Contributor's Contribution.
|
||||
|
||||
1.3. "Contribution"
|
||||
means Covered Software of a particular Contributor.
|
||||
|
||||
1.4. "Covered Software"
|
||||
means Source Code Form to which the initial Contributor has attached
|
||||
the notice in Exhibit A, the Executable Form of such Source Code
|
||||
Form, and Modifications of such Source Code Form, in each case
|
||||
including portions thereof.
|
||||
|
||||
1.5. "Incompatible With Secondary Licenses"
|
||||
means
|
||||
|
||||
(a) that the initial Contributor has attached the notice described
|
||||
in Exhibit B to the Covered Software; or
|
||||
|
||||
(b) that the Covered Software was made available under the terms of
|
||||
version 1.1 or earlier of the License, but not also under the
|
||||
terms of a Secondary License.
|
||||
|
||||
1.6. "Executable Form"
|
||||
means any form of the work other than Source Code Form.
|
||||
|
||||
1.7. "Larger Work"
|
||||
means a work that combines Covered Software with other material, in
|
||||
a separate file or files, that is not Covered Software.
|
||||
|
||||
1.8. "License"
|
||||
means this document.
|
||||
|
||||
1.9. "Licensable"
|
||||
means having the right to grant, to the maximum extent possible,
|
||||
whether at the time of the initial grant or subsequently, any and
|
||||
all of the rights conveyed by this License.
|
||||
|
||||
1.10. "Modifications"
|
||||
means any of the following:
|
||||
|
||||
(a) any file in Source Code Form that results from an addition to,
|
||||
deletion from, or modification of the contents of Covered
|
||||
Software; or
|
||||
|
||||
(b) any new file in Source Code Form that contains any Covered
|
||||
Software.
|
||||
|
||||
1.11. "Patent Claims" of a Contributor
|
||||
means any patent claim(s), including without limitation, method,
|
||||
process, and apparatus claims, in any patent Licensable by such
|
||||
Contributor that would be infringed, but for the grant of the
|
||||
License, by the making, using, selling, offering for sale, having
|
||||
made, import, or transfer of either its Contributions or its
|
||||
Contributor Version.
|
||||
|
||||
1.12. "Secondary License"
|
||||
means either the GNU General Public License, Version 2.0, the GNU
|
||||
Lesser General Public License, Version 2.1, the GNU Affero General
|
||||
Public License, Version 3.0, or any later versions of those
|
||||
licenses.
|
||||
|
||||
1.13. "Source Code Form"
|
||||
means the form of the work preferred for making modifications.
|
||||
|
||||
1.14. "You" (or "Your")
|
||||
means an individual or a legal entity exercising rights under this
|
||||
License. For legal entities, "You" includes any entity that
|
||||
controls, is controlled by, or is under common control with You. For
|
||||
purposes of this definition, "control" means (a) the power, direct
|
||||
or indirect, to cause the direction or management of such entity,
|
||||
whether by contract or otherwise, or (b) ownership of more than
|
||||
fifty percent (50%) of the outstanding shares or beneficial
|
||||
ownership of such entity.
|
||||
|
||||
2. License Grants and Conditions
|
||||
--------------------------------
|
||||
|
||||
2.1. Grants
|
||||
|
||||
Each Contributor hereby grants You a world-wide, royalty-free,
|
||||
non-exclusive license:
|
||||
|
||||
(a) under intellectual property rights (other than patent or trademark)
|
||||
Licensable by such Contributor to use, reproduce, make available,
|
||||
modify, display, perform, distribute, and otherwise exploit its
|
||||
Contributions, either on an unmodified basis, with Modifications, or
|
||||
as part of a Larger Work; and
|
||||
|
||||
(b) under Patent Claims of such Contributor to make, use, sell, offer
|
||||
for sale, have made, import, and otherwise transfer either its
|
||||
Contributions or its Contributor Version.
|
||||
|
||||
2.2. Effective Date
|
||||
|
||||
The licenses granted in Section 2.1 with respect to any Contribution
|
||||
become effective for each Contribution on the date the Contributor first
|
||||
distributes such Contribution.
|
||||
|
||||
2.3. Limitations on Grant Scope
|
||||
|
||||
The licenses granted in this Section 2 are the only rights granted under
|
||||
this License. No additional rights or licenses will be implied from the
|
||||
distribution or licensing of Covered Software under this License.
|
||||
Notwithstanding Section 2.1(b) above, no patent license is granted by a
|
||||
Contributor:
|
||||
|
||||
(a) for any code that a Contributor has removed from Covered Software;
|
||||
or
|
||||
|
||||
(b) for infringements caused by: (i) Your and any other third party's
|
||||
modifications of Covered Software, or (ii) the combination of its
|
||||
Contributions with other software (except as part of its Contributor
|
||||
Version); or
|
||||
|
||||
(c) under Patent Claims infringed by Covered Software in the absence of
|
||||
its Contributions.
|
||||
|
||||
This License does not grant any rights in the trademarks, service marks,
|
||||
or logos of any Contributor (except as may be necessary to comply with
|
||||
the notice requirements in Section 3.4).
|
||||
|
||||
2.4. Subsequent Licenses
|
||||
|
||||
No Contributor makes additional grants as a result of Your choice to
|
||||
distribute the Covered Software under a subsequent version of this
|
||||
License (see Section 10.2) or under the terms of a Secondary License (if
|
||||
permitted under the terms of Section 3.3).
|
||||
|
||||
2.5. Representation
|
||||
|
||||
Each Contributor represents that the Contributor believes its
|
||||
Contributions are its original creation(s) or it has sufficient rights
|
||||
to grant the rights to its Contributions conveyed by this License.
|
||||
|
||||
2.6. Fair Use
|
||||
|
||||
This License is not intended to limit any rights You have under
|
||||
applicable copyright doctrines of fair use, fair dealing, or other
|
||||
equivalents.
|
||||
|
||||
2.7. Conditions
|
||||
|
||||
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
|
||||
in Section 2.1.
|
||||
|
||||
3. Responsibilities
|
||||
-------------------
|
||||
|
||||
3.1. Distribution of Source Form
|
||||
|
||||
All distribution of Covered Software in Source Code Form, including any
|
||||
Modifications that You create or to which You contribute, must be under
|
||||
the terms of this License. You must inform recipients that the Source
|
||||
Code Form of the Covered Software is governed by the terms of this
|
||||
License, and how they can obtain a copy of this License. You may not
|
||||
attempt to alter or restrict the recipients' rights in the Source Code
|
||||
Form.
|
||||
|
||||
3.2. Distribution of Executable Form
|
||||
|
||||
If You distribute Covered Software in Executable Form then:
|
||||
|
||||
(a) such Covered Software must also be made available in Source Code
|
||||
Form, as described in Section 3.1, and You must inform recipients of
|
||||
the Executable Form how they can obtain a copy of such Source Code
|
||||
Form by reasonable means in a timely manner, at a charge no more
|
||||
than the cost of distribution to the recipient; and
|
||||
|
||||
(b) You may distribute such Executable Form under the terms of this
|
||||
License, or sublicense it under different terms, provided that the
|
||||
license for the Executable Form does not attempt to limit or alter
|
||||
the recipients' rights in the Source Code Form under this License.
|
||||
|
||||
3.3. Distribution of a Larger Work
|
||||
|
||||
You may create and distribute a Larger Work under terms of Your choice,
|
||||
provided that You also comply with the requirements of this License for
|
||||
the Covered Software. If the Larger Work is a combination of Covered
|
||||
Software with a work governed by one or more Secondary Licenses, and the
|
||||
Covered Software is not Incompatible With Secondary Licenses, this
|
||||
License permits You to additionally distribute such Covered Software
|
||||
under the terms of such Secondary License(s), so that the recipient of
|
||||
the Larger Work may, at their option, further distribute the Covered
|
||||
Software under the terms of either this License or such Secondary
|
||||
License(s).
|
||||
|
||||
3.4. Notices
|
||||
|
||||
You may not remove or alter the substance of any license notices
|
||||
(including copyright notices, patent notices, disclaimers of warranty,
|
||||
or limitations of liability) contained within the Source Code Form of
|
||||
the Covered Software, except that You may alter any license notices to
|
||||
the extent required to remedy known factual inaccuracies.
|
||||
|
||||
3.5. Application of Additional Terms
|
||||
|
||||
You may choose to offer, and to charge a fee for, warranty, support,
|
||||
indemnity or liability obligations to one or more recipients of Covered
|
||||
Software. However, You may do so only on Your own behalf, and not on
|
||||
behalf of any Contributor. You must make it absolutely clear that any
|
||||
such warranty, support, indemnity, or liability obligation is offered by
|
||||
You alone, and You hereby agree to indemnify every Contributor for any
|
||||
liability incurred by such Contributor as a result of warranty, support,
|
||||
indemnity or liability terms You offer. You may include additional
|
||||
disclaimers of warranty and limitations of liability specific to any
|
||||
jurisdiction.
|
||||
|
||||
4. Inability to Comply Due to Statute or Regulation
|
||||
---------------------------------------------------
|
||||
|
||||
If it is impossible for You to comply with any of the terms of this
|
||||
License with respect to some or all of the Covered Software due to
|
||||
statute, judicial order, or regulation then You must: (a) comply with
|
||||
the terms of this License to the maximum extent possible; and (b)
|
||||
describe the limitations and the code they affect. Such description must
|
||||
be placed in a text file included with all distributions of the Covered
|
||||
Software under this License. Except to the extent prohibited by statute
|
||||
or regulation, such description must be sufficiently detailed for a
|
||||
recipient of ordinary skill to be able to understand it.
|
||||
|
||||
5. Termination
|
||||
--------------
|
||||
|
||||
5.1. The rights granted under this License will terminate automatically
|
||||
if You fail to comply with any of its terms. However, if You become
|
||||
compliant, then the rights granted under this License from a particular
|
||||
Contributor are reinstated (a) provisionally, unless and until such
|
||||
Contributor explicitly and finally terminates Your grants, and (b) on an
|
||||
ongoing basis, if such Contributor fails to notify You of the
|
||||
non-compliance by some reasonable means prior to 60 days after You have
|
||||
come back into compliance. Moreover, Your grants from a particular
|
||||
Contributor are reinstated on an ongoing basis if such Contributor
|
||||
notifies You of the non-compliance by some reasonable means, this is the
|
||||
first time You have received notice of non-compliance with this License
|
||||
from such Contributor, and You become compliant prior to 30 days after
|
||||
Your receipt of the notice.
|
||||
|
||||
5.2. If You initiate litigation against any entity by asserting a patent
|
||||
infringement claim (excluding declaratory judgment actions,
|
||||
counter-claims, and cross-claims) alleging that a Contributor Version
|
||||
directly or indirectly infringes any patent, then the rights granted to
|
||||
You by any and all Contributors for the Covered Software under Section
|
||||
2.1 of this License shall terminate.
|
||||
|
||||
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
|
||||
end user license agreements (excluding distributors and resellers) which
|
||||
have been validly granted by You or Your distributors under this License
|
||||
prior to termination shall survive termination.
|
||||
|
||||
************************************************************************
|
||||
* *
|
||||
* 6. Disclaimer of Warranty *
|
||||
* ------------------------- *
|
||||
* *
|
||||
* Covered Software is provided under this License on an "as is" *
|
||||
* basis, without warranty of any kind, either expressed, implied, or *
|
||||
* statutory, including, without limitation, warranties that the *
|
||||
* Covered Software is free of defects, merchantable, fit for a *
|
||||
* particular purpose or non-infringing. The entire risk as to the *
|
||||
* quality and performance of the Covered Software is with You. *
|
||||
* Should any Covered Software prove defective in any respect, You *
|
||||
* (not any Contributor) assume the cost of any necessary servicing, *
|
||||
* repair, or correction. This disclaimer of warranty constitutes an *
|
||||
* essential part of this License. No use of any Covered Software is *
|
||||
* authorized under this License except under this disclaimer. *
|
||||
* *
|
||||
************************************************************************
|
||||
|
||||
************************************************************************
|
||||
* *
|
||||
* 7. Limitation of Liability *
|
||||
* -------------------------- *
|
||||
* *
|
||||
* Under no circumstances and under no legal theory, whether tort *
|
||||
* (including negligence), contract, or otherwise, shall any *
|
||||
* Contributor, or anyone who distributes Covered Software as *
|
||||
* permitted above, be liable to You for any direct, indirect, *
|
||||
* special, incidental, or consequential damages of any character *
|
||||
* including, without limitation, damages for lost profits, loss of *
|
||||
* goodwill, work stoppage, computer failure or malfunction, or any *
|
||||
* and all other commercial damages or losses, even if such party *
|
||||
* shall have been informed of the possibility of such damages. This *
|
||||
* limitation of liability shall not apply to liability for death or *
|
||||
* personal injury resulting from such party's negligence to the *
|
||||
* extent applicable law prohibits such limitation. Some *
|
||||
* jurisdictions do not allow the exclusion or limitation of *
|
||||
* incidental or consequential damages, so this exclusion and *
|
||||
* limitation may not apply to You. *
|
||||
* *
|
||||
************************************************************************
|
||||
|
||||
8. Litigation
|
||||
-------------
|
||||
|
||||
Any litigation relating to this License may be brought only in the
|
||||
courts of a jurisdiction where the defendant maintains its principal
|
||||
place of business and such litigation shall be governed by laws of that
|
||||
jurisdiction, without reference to its conflict-of-law provisions.
|
||||
Nothing in this Section shall prevent a party's ability to bring
|
||||
cross-claims or counter-claims.
|
||||
|
||||
9. Miscellaneous
|
||||
----------------
|
||||
|
||||
This License represents the complete agreement concerning the subject
|
||||
matter hereof. If any provision of this License is held to be
|
||||
unenforceable, such provision shall be reformed only to the extent
|
||||
necessary to make it enforceable. Any law or regulation which provides
|
||||
that the language of a contract shall be construed against the drafter
|
||||
shall not be used to construe this License against a Contributor.
|
||||
|
||||
10. Versions of the License
|
||||
---------------------------
|
||||
|
||||
10.1. New Versions
|
||||
|
||||
Mozilla Foundation is the license steward. Except as provided in Section
|
||||
10.3, no one other than the license steward has the right to modify or
|
||||
publish new versions of this License. Each version will be given a
|
||||
distinguishing version number.
|
||||
|
||||
10.2. Effect of New Versions
|
||||
|
||||
You may distribute the Covered Software under the terms of the version
|
||||
of the License under which You originally received the Covered Software,
|
||||
or under the terms of any subsequent version published by the license
|
||||
steward.
|
||||
|
||||
10.3. Modified Versions
|
||||
|
||||
If you create software not governed by this License, and you want to
|
||||
create a new license for such software, you may create and use a
|
||||
modified version of this License if you rename the license and remove
|
||||
any references to the name of the license steward (except to note that
|
||||
such modified license differs from this License).
|
||||
|
||||
10.4. Distributing Source Code Form that is Incompatible With Secondary
|
||||
Licenses
|
||||
|
||||
If You choose to distribute Source Code Form that is Incompatible With
|
||||
Secondary Licenses under the terms of this version of the License, the
|
||||
notice described in Exhibit B of this License must be attached.
|
||||
|
||||
Exhibit A - Source Code Form License Notice
|
||||
-------------------------------------------
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
|
||||
If it is not possible or desirable to put the notice in a particular
|
||||
file, then You may include the notice in a location (such as a LICENSE
|
||||
file in a relevant directory) where a recipient would be likely to look
|
||||
for such a notice.
|
||||
|
||||
You may add additional accurate notices of copyright ownership.
|
||||
|
||||
Exhibit B - "Incompatible With Secondary Licenses" Notice
|
||||
---------------------------------------------------------
|
||||
|
||||
This Source Code Form is "Incompatible With Secondary Licenses", as
|
||||
defined by the Mozilla Public License, v. 2.0.
|
||||
@@ -0,0 +1,387 @@
|
||||
# This Source Code Form is subject to the terms of the Mozilla Public
|
||||
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
|
||||
import os
|
||||
import logging
|
||||
import binascii
|
||||
import time
|
||||
import re
|
||||
import copy
|
||||
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives.asymmetric import ec, utils as ecutils
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
|
||||
from py_vapid.utils import b64urldecode, b64urlencode
|
||||
from py_vapid.jwt import sign
|
||||
|
||||
# Show compliance version. For earlier versions see previously tagged releases.
|
||||
VERSION = "VAPID-RFC/ECE-RFC"
|
||||
|
||||
|
||||
class VapidException(Exception):
|
||||
"""An exception wrapper for Vapid."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class Vapid01(object):
|
||||
"""Minimal VAPID Draft 01 signature generation library.
|
||||
|
||||
https://tools.ietf.org/html/draft-ietf-webpush-vapid-01
|
||||
|
||||
"""
|
||||
|
||||
_private_key = None
|
||||
_public_key = None
|
||||
_schema = "WebPush"
|
||||
|
||||
def __init__(self, private_key=None, conf=None):
|
||||
"""Initialize VAPID with an optional private key.
|
||||
|
||||
:param private_key: A private key object
|
||||
:type private_key: ec.EllipticCurvePrivateKey
|
||||
|
||||
"""
|
||||
if conf is None:
|
||||
conf = {}
|
||||
self.conf = conf
|
||||
self.private_key = private_key
|
||||
if private_key:
|
||||
self._public_key = self.private_key.public_key()
|
||||
|
||||
@classmethod
|
||||
def from_raw(cls, private_raw):
|
||||
"""Initialize VAPID using a private key point in "raw" or
|
||||
"uncompressed" form. Raw keys consist of a single, 32 octet
|
||||
encoded integer.
|
||||
|
||||
:param private_raw: A private key point in uncompressed form.
|
||||
:type private_raw: bytes
|
||||
|
||||
"""
|
||||
key = ec.derive_private_key(
|
||||
int(binascii.hexlify(b64urldecode(private_raw)), 16),
|
||||
curve=ec.SECP256R1(),
|
||||
backend=default_backend(),
|
||||
)
|
||||
return cls(key)
|
||||
|
||||
@classmethod
|
||||
def from_raw_public(cls, public_raw):
|
||||
key = ec.EllipticCurvePublicKey.from_encoded_point(
|
||||
curve=ec.SECP256R1(), data=b64urldecode(public_raw)
|
||||
)
|
||||
ss = cls()
|
||||
ss._public_key = key
|
||||
return ss
|
||||
|
||||
@classmethod
|
||||
def from_pem(cls, private_key):
|
||||
"""Initialize VAPID using a private key in PEM format.
|
||||
|
||||
:param private_key: A private key in PEM format.
|
||||
:type private_key: bytes
|
||||
|
||||
"""
|
||||
# not sure why, but load_pem_private_key fails to deserialize
|
||||
return cls.from_der(b"".join(private_key.splitlines()[1:-1]))
|
||||
|
||||
@classmethod
|
||||
def from_der(cls, private_key):
|
||||
"""Initialize VAPID using a private key in DER format.
|
||||
|
||||
:param private_key: A private key in DER format and Base64-encoded.
|
||||
:type private_key: bytes
|
||||
|
||||
"""
|
||||
key = serialization.load_der_private_key(
|
||||
b64urldecode(private_key), password=None, backend=default_backend()
|
||||
)
|
||||
return cls(key)
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, private_key_file=None):
|
||||
"""Initialize VAPID using a file containing a private key in PEM or
|
||||
DER format.
|
||||
|
||||
:param private_key_file: Name of the file containing the private key
|
||||
:type private_key_file: str
|
||||
|
||||
"""
|
||||
if not os.path.isfile(private_key_file):
|
||||
logging.info("Private key not found, generating key...")
|
||||
vapid = cls()
|
||||
vapid.generate_keys()
|
||||
vapid.save_key(private_key_file)
|
||||
return vapid
|
||||
with open(private_key_file, "r") as file:
|
||||
private_key = file.read()
|
||||
try:
|
||||
if "-----BEGIN" in private_key:
|
||||
vapid = cls.from_pem(private_key.encode("utf8"))
|
||||
else:
|
||||
vapid = cls.from_der(private_key.encode("utf8"))
|
||||
return vapid
|
||||
except Exception as exc:
|
||||
logging.error("Could not open private key file: %s", repr(exc))
|
||||
raise VapidException(exc)
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, private_key):
|
||||
"""Initialize VAPID using a string containing the private key. This
|
||||
will try to determine if the key is in RAW or DER format.
|
||||
|
||||
:param private_key: String containing the key info
|
||||
:type private_key: str
|
||||
|
||||
"""
|
||||
|
||||
pkey = private_key.encode().replace(b"\n", b"")
|
||||
key = b64urldecode(pkey)
|
||||
if len(key) == 32:
|
||||
return cls.from_raw(pkey)
|
||||
return cls.from_der(pkey)
|
||||
|
||||
@classmethod
|
||||
def verify(cls, key, auth):
|
||||
"""Verify a VAPID authorization token.
|
||||
|
||||
:param key: base64 serialized public key
|
||||
:type key: str
|
||||
:param auth: authorization token
|
||||
type key: str
|
||||
|
||||
"""
|
||||
tokens = auth.rsplit(" ", 1)[1].rsplit(".", 1)
|
||||
kp = cls().from_raw_public(key.encode())
|
||||
return kp.verify_token(
|
||||
validation_token=tokens[0].encode(), verification_token=tokens[1]
|
||||
)
|
||||
|
||||
@property
|
||||
def private_key(self):
|
||||
"""The VAPID private ECDSA key"""
|
||||
if not self._private_key:
|
||||
raise VapidException("No private key. Call generate_keys()")
|
||||
return self._private_key
|
||||
|
||||
@private_key.setter
|
||||
def private_key(self, value):
|
||||
"""Set the VAPID private ECDSA key
|
||||
|
||||
:param value: the byte array containing the private ECDSA key data
|
||||
:type value: ec.EllipticCurvePrivateKey
|
||||
|
||||
"""
|
||||
self._private_key = value
|
||||
if value:
|
||||
self._public_key = self.private_key.public_key()
|
||||
|
||||
@property
|
||||
def public_key(self):
|
||||
"""The VAPID public ECDSA key
|
||||
|
||||
The public key is currently read only. Set it via the `.private_key`
|
||||
method. This will autogenerate a public and private key if no value
|
||||
has been set.
|
||||
|
||||
:returns ec.EllipticCurvePublicKey
|
||||
|
||||
"""
|
||||
return self._public_key
|
||||
|
||||
def generate_keys(self):
|
||||
"""Generate a valid ECDSA Key Pair."""
|
||||
self.private_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
|
||||
|
||||
def private_pem(self):
|
||||
return self.private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
|
||||
def public_pem(self):
|
||||
return self.public_key.public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
)
|
||||
|
||||
def save_key(self, key_file):
|
||||
"""Save the private key to a PEM file.
|
||||
|
||||
:param key_file: The file path to save the private key data
|
||||
:type key_file: str
|
||||
|
||||
"""
|
||||
with open(key_file, "wb") as file:
|
||||
file.write(self.private_pem())
|
||||
file.close()
|
||||
|
||||
def save_public_key(self, key_file):
|
||||
"""Save the public key to a PEM file.
|
||||
:param key_file: The name of the file to save the public key
|
||||
:type key_file: str
|
||||
|
||||
"""
|
||||
with open(key_file, "wb") as file:
|
||||
file.write(self.public_pem())
|
||||
file.close()
|
||||
|
||||
def verify_token(self, validation_token, verification_token):
|
||||
"""Internally used to verify the verification token is correct.
|
||||
|
||||
:param validation_token: Provided validation token string
|
||||
:type validation_token: str
|
||||
:param verification_token: Generated verification token
|
||||
:type verification_token: str
|
||||
:returns: Boolean indicating if verifictation token is valid.
|
||||
:rtype: boolean
|
||||
|
||||
"""
|
||||
hsig = b64urldecode(verification_token.encode("utf8"))
|
||||
r = int(binascii.hexlify(hsig[:32]), 16)
|
||||
s = int(binascii.hexlify(hsig[32:]), 16)
|
||||
try:
|
||||
self.public_key.verify(
|
||||
ecutils.encode_dss_signature(r, s),
|
||||
validation_token,
|
||||
signature_algorithm=ec.ECDSA(hashes.SHA256()),
|
||||
)
|
||||
return True
|
||||
except InvalidSignature:
|
||||
return False
|
||||
|
||||
def _base_sign(self, claims):
|
||||
cclaims = copy.deepcopy(claims)
|
||||
if not cclaims.get("exp"):
|
||||
cclaims["exp"] = int(time.time()) + 86400
|
||||
if not self.conf.get("no-strict", False):
|
||||
valid = _check_sub(cclaims.get("sub", ""))
|
||||
else:
|
||||
valid = cclaims.get("sub") is not None
|
||||
if not valid:
|
||||
raise VapidException(
|
||||
"Missing 'sub' from claims. "
|
||||
"'sub' is your admin email as a mailto: link."
|
||||
)
|
||||
if not re.match(
|
||||
r"^https?://[^/:]+(:\d+)?$", cclaims.get("aud", ""), re.IGNORECASE
|
||||
):
|
||||
raise VapidException(
|
||||
"Missing 'aud' from claims. "
|
||||
"'aud' is the scheme, host and optional port for this "
|
||||
"transaction e.g. https://example.com:8080"
|
||||
)
|
||||
return cclaims
|
||||
|
||||
def sign(self, claims, crypto_key=None):
|
||||
"""Sign a set of claims.
|
||||
:param claims: JSON object containing the JWT claims to use.
|
||||
:type claims: dict
|
||||
:param crypto_key: Optional existing crypto_key header content. The
|
||||
vapid public key will be appended to this data.
|
||||
:type crypto_key: str
|
||||
:returns: a hash containing the header fields to use in
|
||||
the subscription update.
|
||||
:rtype: dict
|
||||
|
||||
"""
|
||||
sig = sign(self._base_sign(claims), self.private_key)
|
||||
pkey = "p256ecdsa="
|
||||
pkey += b64urlencode(
|
||||
self.public_key.public_bytes(
|
||||
serialization.Encoding.X962,
|
||||
serialization.PublicFormat.UncompressedPoint,
|
||||
)
|
||||
)
|
||||
if crypto_key:
|
||||
crypto_key = crypto_key + ";" + pkey
|
||||
else:
|
||||
crypto_key = pkey
|
||||
|
||||
return {
|
||||
"Authorization": "{} {}".format(self._schema, sig.strip("=")),
|
||||
"Crypto-Key": crypto_key,
|
||||
}
|
||||
|
||||
|
||||
class Vapid02(Vapid01):
|
||||
"""Minimal Vapid RFC8292 signature generation library
|
||||
|
||||
https://tools.ietf.org/html/rfc8292
|
||||
|
||||
"""
|
||||
|
||||
_schema = "vapid"
|
||||
|
||||
def sign(self, claims, crypto_key=None):
|
||||
"""Generate an authorization token
|
||||
|
||||
:param claims: JSON object containing the JWT claims to use.
|
||||
:type claims: dict
|
||||
:param crypto_key: Optional existing crypto_key header content. The
|
||||
vapid public key will be appended to this data.
|
||||
:type crypto_key: str
|
||||
:returns: a hash containing the header fields to use in
|
||||
the subscription update.
|
||||
:rtype: dict
|
||||
"""
|
||||
sig = sign(self._base_sign(claims), self.private_key)
|
||||
pkey = self.public_key.public_bytes(
|
||||
serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint
|
||||
)
|
||||
return {
|
||||
"Authorization": "{schema} t={t},k={k}".format(
|
||||
schema=self._schema, t=sig, k=b64urlencode(pkey)
|
||||
)
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def verify(cls, auth):
|
||||
"""Ensure that the token is correctly formatted and valid
|
||||
|
||||
:param auth: An Authorization header
|
||||
:type auth: str
|
||||
:rtype: bool
|
||||
|
||||
"""
|
||||
pref_tok = auth.rsplit(" ", 1)
|
||||
assert pref_tok[0].lower() == cls._schema, "Incorrect schema specified"
|
||||
parts = {}
|
||||
for tok in pref_tok[1].split(","):
|
||||
kv = tok.split("=", 1)
|
||||
parts[kv[0]] = kv[1]
|
||||
assert "k" in parts.keys(), "Auth missing public key 'k' value"
|
||||
assert "t" in parts.keys(), "Auth missing token set 't' value"
|
||||
kp = cls().from_raw_public(parts["k"].encode())
|
||||
tokens = parts["t"].rsplit(".", 1)
|
||||
return kp.verify_token(
|
||||
validation_token=tokens[0].encode(), verification_token=tokens[1]
|
||||
)
|
||||
|
||||
|
||||
def _check_sub(sub):
|
||||
"""Check to see if the `sub` is a properly formatted `mailto:`
|
||||
|
||||
a `mailto:` should be a SMTP mail address. Mind you, since I run
|
||||
YouFailAtEmail.com, you have every right to yell about how terrible
|
||||
this check is. I really should be doing a proper component parse
|
||||
and valiate each component individually per RFC5341, instead I do
|
||||
the unholy regex you see below.
|
||||
|
||||
:param sub: Candidate JWT `sub`
|
||||
:type sub: str
|
||||
:rtype: bool
|
||||
|
||||
"""
|
||||
pattern = r"^(mailto:.+@((localhost|[%\w-]+(\.[%\w-]+)+|([0-9a-f]{1,4}):+([0-9a-f]{1,4})?)))|https:\/\/(localhost|[\w-]+\.[\w\.-]+|([0-9a-f]{1,4}:+)+([0-9a-f]{1,4})?)$" # noqa
|
||||
return re.match(pattern, sub, re.IGNORECASE) is not None
|
||||
|
||||
|
||||
Vapid = Vapid02
|
||||
@@ -0,0 +1,137 @@
|
||||
# This Source Code Form is subject to the terms of the Mozilla Public
|
||||
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import json
|
||||
|
||||
from typing import cast
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from py_vapid import Vapid01, Vapid02, b64urlencode
|
||||
|
||||
|
||||
def prompt(prompt: str) -> str:
|
||||
# Not sure why, but python3 throws and exception if you try to
|
||||
# monkeypatch for this. It's ugly, but this seems to play nicer.
|
||||
try:
|
||||
return input(prompt)
|
||||
except NameError:
|
||||
return raw_input(prompt) # noqa: F821
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="VAPID tool")
|
||||
parser.add_argument("--sign", "-s", help="claims file to sign")
|
||||
parser.add_argument(
|
||||
"--gen", "-g", help="generate new key pairs", default=False, action="store_true"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--version2",
|
||||
"-2",
|
||||
help="use RFC8292 VAPID spec",
|
||||
default=True,
|
||||
action="store_true",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--version1",
|
||||
"-1",
|
||||
help="use VAPID spec Draft-01",
|
||||
default=False,
|
||||
action="store_true",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json", help="dump as json", default=False, action="store_true"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-strict",
|
||||
help='Do not be strict about "sub"',
|
||||
default=False,
|
||||
action="store_true",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--applicationServerKey",
|
||||
help="show applicationServerKey value",
|
||||
default=False,
|
||||
action="store_true",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--private-key", "-k", help="private key pem file", default="private_key.pem"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Added to solve 2.7 => 3.* incompatibility
|
||||
# This library advocates for Vapid02
|
||||
Vapid = Vapid02
|
||||
if args.version1:
|
||||
Vapid = Vapid01
|
||||
if args.gen or not os.path.exists(args.private_key):
|
||||
if not args.gen:
|
||||
print("No private key file found.")
|
||||
answer = None
|
||||
while answer not in ["y", "n"]:
|
||||
answer = prompt("Do you want me to create one for you? (Y/n)")
|
||||
if not answer:
|
||||
answer = "y"
|
||||
answer = answer.lower()[0]
|
||||
if answer == "n":
|
||||
print("Sorry, can't do much for you then.")
|
||||
exit(1)
|
||||
|
||||
vapid = Vapid(conf=vars(args))
|
||||
vapid.generate_keys()
|
||||
print("Generating private_key.pem")
|
||||
vapid.save_key("private_key.pem")
|
||||
print("Generating public_key.pem")
|
||||
vapid.save_public_key("public_key.pem")
|
||||
vapid = Vapid.from_file(args.private_key)
|
||||
claim_file = args.sign
|
||||
result = dict()
|
||||
if args.applicationServerKey:
|
||||
raw_pub = vapid.public_key.public_bytes(
|
||||
serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint
|
||||
)
|
||||
print("Application Server Key = {}\n\n".format(b64urlencode(raw_pub)))
|
||||
if claim_file:
|
||||
if not os.path.exists(claim_file):
|
||||
print("No {} file found.".format(claim_file))
|
||||
print(
|
||||
"""
|
||||
The claims file should be a JSON formatted file that holds the
|
||||
information that describes you. There are three elements in the claims
|
||||
file you'll need:
|
||||
|
||||
"sub" This is your site's admin email address
|
||||
(e.g. "mailto:admin@example.com")
|
||||
"exp" This is the expiration time for the claim in seconds. If you don't
|
||||
have one, I'll add one that expires in 24 hours.
|
||||
|
||||
You're also welcome to add additional fields to the claims which could be
|
||||
helpful for the Push Service operations team to pass along to your operations
|
||||
team (e.g. "ami-id": "e-123456", "cust-id": "a3sfa10987"). Remember to keep
|
||||
these values short to prevent some servers from rejecting the transaction due
|
||||
to overly large headers. See https://jwt.io/introduction/ for details.
|
||||
|
||||
For example, a claims.json file could contain:
|
||||
|
||||
{"sub": "mailto:admin@example.com"}
|
||||
"""
|
||||
)
|
||||
exit(1)
|
||||
try:
|
||||
claims = json.loads(open(claim_file).read())
|
||||
result.update(vapid.sign(claims))
|
||||
except Exception as barrier:
|
||||
print("Crap, something went wrong: {}".format(repr(barrier)))
|
||||
raise barrier
|
||||
if args.json:
|
||||
print(json.dumps(result))
|
||||
return
|
||||
print("Include the following headers in your request:\n")
|
||||
for key, value in result.items():
|
||||
print("{}: {}\n".format(key, value))
|
||||
print("\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,87 @@
|
||||
import binascii
|
||||
import json
|
||||
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives.asymmetric import ec, utils
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
|
||||
from py_vapid.utils import b64urldecode, b64urlencode, num_to_bytes
|
||||
|
||||
|
||||
def extract_signature(auth):
|
||||
"""Extracts the payload and signature from a JWT, converting from RFC7518
|
||||
to RFC 3279
|
||||
|
||||
:param auth: A JWT Authorization Token.
|
||||
:type auth: str
|
||||
|
||||
:return tuple containing the signature material and signature
|
||||
|
||||
"""
|
||||
payload, asig = auth.encode('utf8').rsplit(b'.', 1)
|
||||
sig = b64urldecode(asig)
|
||||
if len(sig) != 64:
|
||||
raise InvalidSignature()
|
||||
|
||||
encoded = utils.encode_dss_signature(
|
||||
s=int(binascii.hexlify(sig[32:]), 16),
|
||||
r=int(binascii.hexlify(sig[:32]), 16)
|
||||
)
|
||||
return payload, encoded
|
||||
|
||||
|
||||
def decode(token, key):
|
||||
"""Decode a web token into an assertion dictionary
|
||||
|
||||
:param token: VAPID auth token
|
||||
:type token: str
|
||||
:param key: bitarray containing the public key
|
||||
:type key: str
|
||||
|
||||
:return dict of the VAPID claims
|
||||
|
||||
:raise InvalidSignature
|
||||
|
||||
"""
|
||||
try:
|
||||
sig_material, signature = extract_signature(token)
|
||||
dkey = b64urldecode(key.encode('utf8'))
|
||||
pkey = ec.EllipticCurvePublicKey.from_encoded_point(
|
||||
ec.SECP256R1(),
|
||||
dkey,
|
||||
)
|
||||
pkey.verify(
|
||||
signature,
|
||||
sig_material,
|
||||
ec.ECDSA(hashes.SHA256())
|
||||
)
|
||||
return json.loads(
|
||||
b64urldecode(sig_material.split(b'.')[1]).decode('utf8')
|
||||
)
|
||||
except InvalidSignature:
|
||||
raise
|
||||
except(ValueError, TypeError, binascii.Error):
|
||||
raise InvalidSignature()
|
||||
|
||||
|
||||
def sign(claims, key):
|
||||
"""Sign the claims
|
||||
|
||||
:param claims: list of JWS claims
|
||||
:type claims: dict
|
||||
:param key: Private key for signing
|
||||
:type key: ec.EllipticCurvePrivateKey
|
||||
:param algorithm: JWT "alg" descriptor
|
||||
:type algorithm: str
|
||||
|
||||
"""
|
||||
header = b64urlencode(b"""{"typ":"JWT","alg":"ES256"}""")
|
||||
# Unfortunately, chrome seems to require the claims to be sorted.
|
||||
claims = b64urlencode(json.dumps(claims,
|
||||
separators=(',', ':'),
|
||||
sort_keys=True).encode('utf8'))
|
||||
token = "{}.{}".format(header, claims)
|
||||
rsig = key.sign(token.encode('utf8'), ec.ECDSA(hashes.SHA256()))
|
||||
(r, s) = utils.decode_dss_signature(rsig)
|
||||
sig = b64urlencode(num_to_bytes(r, 32) + num_to_bytes(s, 32))
|
||||
return "{}.{}".format(token, sig)
|
||||
@@ -0,0 +1,115 @@
|
||||
# This Source Code Form is subject to the terms of the Mozilla Public
|
||||
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import json
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
|
||||
from py_vapid import Vapid01, Vapid02, b64urlencode
|
||||
|
||||
|
||||
def prompt(prompt):
|
||||
# Not sure why, but python3 throws and exception if you try to
|
||||
# monkeypatch for this. It's ugly, but this seems to play nicer.
|
||||
try:
|
||||
return input(prompt)
|
||||
except NameError:
|
||||
return raw_input(prompt) # noqa: F821
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="VAPID tool")
|
||||
parser.add_argument('--sign', '-s', help='claims file to sign')
|
||||
parser.add_argument('--gen', '-g', help='generate new key pairs',
|
||||
default=False, action="store_true")
|
||||
parser.add_argument('--version2', '-2', help="use RFC8292 VAPID spec",
|
||||
default=True, action="store_true")
|
||||
parser.add_argument('--version1', '-1', help="use VAPID spec Draft-01",
|
||||
default=False, action="store_true")
|
||||
parser.add_argument('--json', help="dump as json",
|
||||
default=False, action="store_true")
|
||||
parser.add_argument('--no-strict', help='Do not be strict about "sub"',
|
||||
default=False, action="store_true")
|
||||
parser.add_argument('--applicationServerKey',
|
||||
help="show applicationServerKey value",
|
||||
default=False, action="store_true")
|
||||
parser.add_argument('--private-key', '-k', help='private key pem file',
|
||||
default="private_key.pem")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Added to solve 2.7 => 3.* incompatibility
|
||||
Vapid = Vapid02
|
||||
if args.version1:
|
||||
Vapid = Vapid01
|
||||
if args.gen or not os.path.exists(args.private_key):
|
||||
if not args.gen:
|
||||
print("No private key file found.")
|
||||
answer = None
|
||||
while answer not in ['y', 'n']:
|
||||
answer = prompt("Do you want me to create one for you? (Y/n)")
|
||||
if not answer:
|
||||
answer = 'y'
|
||||
answer = answer.lower()[0]
|
||||
if answer == 'n':
|
||||
print("Sorry, can't do much for you then.")
|
||||
exit(1)
|
||||
vapid = Vapid(conf=args)
|
||||
vapid.generate_keys()
|
||||
print("Generating private_key.pem")
|
||||
vapid.save_key('private_key.pem')
|
||||
print("Generating public_key.pem")
|
||||
vapid.save_public_key('public_key.pem')
|
||||
vapid = Vapid.from_file(args.private_key)
|
||||
claim_file = args.sign
|
||||
result = dict()
|
||||
if args.applicationServerKey:
|
||||
raw_pub = vapid.public_key.public_bytes(
|
||||
serialization.Encoding.X962,
|
||||
serialization.PublicFormat.UncompressedPoint
|
||||
)
|
||||
print("Application Server Key = {}\n\n".format(
|
||||
b64urlencode(raw_pub)))
|
||||
if claim_file:
|
||||
if not os.path.exists(claim_file):
|
||||
print("No {} file found.".format(claim_file))
|
||||
print("""
|
||||
The claims file should be a JSON formatted file that holds the
|
||||
information that describes you. There are three elements in the claims
|
||||
file you'll need:
|
||||
|
||||
"sub" This is your site's admin email address
|
||||
(e.g. "mailto:admin@example.com")
|
||||
"exp" This is the expiration time for the claim in seconds. If you don't
|
||||
have one, I'll add one that expires in 24 hours.
|
||||
|
||||
You're also welcome to add additional fields to the claims which could be
|
||||
helpful for the Push Service operations team to pass along to your operations
|
||||
team (e.g. "ami-id": "e-123456", "cust-id": "a3sfa10987"). Remember to keep
|
||||
these values short to prevent some servers from rejecting the transaction due
|
||||
to overly large headers. See https://jwt.io/introduction/ for details.
|
||||
|
||||
For example, a claims.json file could contain:
|
||||
|
||||
{"sub": "mailto:admin@example.com"}
|
||||
""")
|
||||
exit(1)
|
||||
try:
|
||||
claims = json.loads(open(claim_file).read())
|
||||
result.update(vapid.sign(claims))
|
||||
except Exception as exc:
|
||||
print("Crap, something went wrong: {}".format(repr(exc)))
|
||||
raise exc
|
||||
if args.json:
|
||||
print(json.dumps(result))
|
||||
return
|
||||
print("Include the following headers in your request:\n")
|
||||
for key, value in result.items():
|
||||
print("{}: {}\n".format(key, value))
|
||||
print("\n")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,282 @@
|
||||
import binascii
|
||||
import base64
|
||||
import copy
|
||||
import os
|
||||
import json
|
||||
import unittest
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from mock import patch, Mock
|
||||
|
||||
from py_vapid import Vapid01, Vapid02, VapidException, _check_sub
|
||||
from py_vapid.jwt import decode
|
||||
|
||||
TEST_KEY_PRIVATE_DER = """
|
||||
MHcCAQEEIPeN1iAipHbt8+/KZ2NIF8NeN24jqAmnMLFZEMocY8RboAoGCCqGSM49
|
||||
AwEHoUQDQgAEEJwJZq/GN8jJbo1GGpyU70hmP2hbWAUpQFKDByKB81yldJ9GTklB
|
||||
M5xqEwuPM7VuQcyiLDhvovthPIXx+gsQRQ==
|
||||
"""
|
||||
|
||||
key = dict(
|
||||
d=111971876876285331364078054667935803036831194031221090723024134705696601261147, # noqa
|
||||
x=7512698603580564493364310058109115206932767156853859985379597995200661812060, # noqa
|
||||
y=74837673548863147047276043384733294240255217876718360423043754089982135570501 # noqa
|
||||
)
|
||||
|
||||
# This is the same private key, in PEM form.
|
||||
TEST_KEY_PRIVATE_PEM = (
|
||||
"-----BEGIN PRIVATE KEY-----{}"
|
||||
"-----END PRIVATE KEY-----\n").format(TEST_KEY_PRIVATE_DER)
|
||||
|
||||
# This is the same private key, as a point in uncompressed form. This should
|
||||
# be Base64url-encoded without padding.
|
||||
TEST_KEY_PRIVATE_RAW = """
|
||||
943WICKkdu3z78pnY0gXw143biOoCacwsVkQyhxjxFs
|
||||
""".strip().encode('utf8')
|
||||
|
||||
# This is a public key in PEM form.
|
||||
TEST_KEY_PUBLIC_PEM = """-----BEGIN PUBLIC KEY-----
|
||||
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEEJwJZq/GN8jJbo1GGpyU70hmP2hb
|
||||
WAUpQFKDByKB81yldJ9GTklBM5xqEwuPM7VuQcyiLDhvovthPIXx+gsQRQ==
|
||||
-----END PUBLIC KEY-----
|
||||
"""
|
||||
|
||||
# this is a public key in uncompressed form ('\x04' + 2 * 32 octets)
|
||||
# Remember, this should have any padding stripped.
|
||||
TEST_KEY_PUBLIC_RAW = (
|
||||
"BBCcCWavxjfIyW6NRhqclO9IZj9oW1gFKUBSgwcigfNc"
|
||||
"pXSfRk5JQTOcahMLjzO1bkHMoiw4b6L7YTyF8foLEEU"
|
||||
).strip('=').encode('utf8')
|
||||
|
||||
|
||||
def setup_module(self):
|
||||
with open('/tmp/private', 'w') as ff:
|
||||
ff.write(TEST_KEY_PRIVATE_PEM)
|
||||
with open('/tmp/public', 'w') as ff:
|
||||
ff.write(TEST_KEY_PUBLIC_PEM)
|
||||
with open('/tmp/private.der', 'w') as ff:
|
||||
ff.write(TEST_KEY_PRIVATE_DER)
|
||||
|
||||
|
||||
def teardown_module(self):
|
||||
os.unlink('/tmp/private')
|
||||
os.unlink('/tmp/public')
|
||||
|
||||
|
||||
class VapidTestCase(unittest.TestCase):
|
||||
def check_keys(self, v):
|
||||
assert v.private_key.private_numbers().private_value == key.get('d')
|
||||
assert v.public_key.public_numbers().x == key.get('x')
|
||||
assert v.public_key.public_numbers().y == key.get('y')
|
||||
|
||||
def test_init(self):
|
||||
v1 = Vapid01.from_file("/tmp/private")
|
||||
self.check_keys(v1)
|
||||
v2 = Vapid01.from_pem(TEST_KEY_PRIVATE_PEM.encode())
|
||||
self.check_keys(v2)
|
||||
v3 = Vapid01.from_der(TEST_KEY_PRIVATE_DER.encode())
|
||||
self.check_keys(v3)
|
||||
v4 = Vapid01.from_file("/tmp/private.der")
|
||||
self.check_keys(v4)
|
||||
no_exist = '/tmp/not_exist'
|
||||
Vapid01.from_file(no_exist)
|
||||
assert os.path.isfile(no_exist)
|
||||
os.unlink(no_exist)
|
||||
|
||||
def repad(self, data):
|
||||
return data + "===="[len(data) % 4:]
|
||||
|
||||
@patch("py_vapid.Vapid01.from_pem", side_effect=Exception)
|
||||
def test_init_bad_read(self, mm):
|
||||
self.assertRaises(Exception,
|
||||
Vapid01.from_file,
|
||||
private_key_file="/tmp/private")
|
||||
|
||||
def test_gen_key(self):
|
||||
v = Vapid01()
|
||||
v.generate_keys()
|
||||
assert v.public_key
|
||||
assert v.private_key
|
||||
|
||||
def test_private_key(self):
|
||||
v = Vapid01()
|
||||
self.assertRaises(VapidException,
|
||||
lambda: v.private_key)
|
||||
|
||||
def test_public_key(self):
|
||||
v = Vapid01()
|
||||
assert v._private_key is None
|
||||
assert v._public_key is None
|
||||
|
||||
def test_save_key(self):
|
||||
v = Vapid01()
|
||||
v.generate_keys()
|
||||
v.save_key("/tmp/p2")
|
||||
os.unlink("/tmp/p2")
|
||||
|
||||
def test_same_public_key(self):
|
||||
v = Vapid01()
|
||||
v.generate_keys()
|
||||
v.save_public_key("/tmp/p2")
|
||||
os.unlink("/tmp/p2")
|
||||
|
||||
def test_from_raw(self):
|
||||
v = Vapid01.from_raw(TEST_KEY_PRIVATE_RAW)
|
||||
self.check_keys(v)
|
||||
|
||||
def test_from_string(self):
|
||||
v1 = Vapid01.from_string(TEST_KEY_PRIVATE_DER)
|
||||
v2 = Vapid01.from_string(TEST_KEY_PRIVATE_RAW.decode())
|
||||
self.check_keys(v1)
|
||||
self.check_keys(v2)
|
||||
|
||||
def test_sign_01(self):
|
||||
v = Vapid01.from_string(TEST_KEY_PRIVATE_DER)
|
||||
claims = {"aud": "https://example.com",
|
||||
"sub": "mailto:admin@example.com"}
|
||||
result = v.sign(claims, "id=previous")
|
||||
assert result['Crypto-Key'] == (
|
||||
'id=previous;p256ecdsa=' + TEST_KEY_PUBLIC_RAW.decode('utf8'))
|
||||
pkey = binascii.b2a_base64(
|
||||
v.public_key.public_bytes(
|
||||
serialization.Encoding.X962,
|
||||
serialization.PublicFormat.UncompressedPoint
|
||||
)
|
||||
).decode('utf8').replace('+', '-').replace('/', '_').strip()
|
||||
items = decode(result['Authorization'].split(' ')[1], pkey)
|
||||
for k in claims:
|
||||
assert items[k] == claims[k]
|
||||
result = v.sign(claims)
|
||||
assert result['Crypto-Key'] == (
|
||||
'p256ecdsa=' + TEST_KEY_PUBLIC_RAW.decode('utf8'))
|
||||
# Verify using the same function as Integration
|
||||
# this should ensure that the r,s sign values are correctly formed
|
||||
assert Vapid01.verify(
|
||||
key=result['Crypto-Key'].split('=')[1],
|
||||
auth=result['Authorization']
|
||||
)
|
||||
|
||||
def test_sign_02(self):
|
||||
v = Vapid02.from_file("/tmp/private")
|
||||
claims = {"aud": "https://example.com",
|
||||
"sub": "mailto:admin@example.com",
|
||||
"foo": "extra value"}
|
||||
claim_check = copy.deepcopy(claims)
|
||||
result = v.sign(claims, "id=previous")
|
||||
auth = result['Authorization']
|
||||
assert auth[:6] == 'vapid '
|
||||
assert ' t=' in auth
|
||||
assert ',k=' in auth
|
||||
parts = auth[6:].split(',')
|
||||
assert len(parts) == 2
|
||||
t_val = json.loads(base64.urlsafe_b64decode(
|
||||
self.repad(parts[0][2:].split('.')[1])
|
||||
).decode('utf8'))
|
||||
k_val = binascii.a2b_base64(self.repad(parts[1][2:]))
|
||||
assert binascii.hexlify(k_val)[:2] == b'04'
|
||||
assert len(k_val) == 65
|
||||
assert claims == claim_check
|
||||
for k in claims:
|
||||
assert t_val[k] == claims[k]
|
||||
|
||||
def test_sign_02_localhost(self):
|
||||
v = Vapid02.from_file("/tmp/private")
|
||||
claims = {"aud": "http://localhost:8000",
|
||||
"sub": "mailto:admin@example.com",
|
||||
"foo": "extra value"}
|
||||
result = v.sign(claims, "id=previous")
|
||||
auth = result['Authorization']
|
||||
assert auth[:6] == 'vapid '
|
||||
assert ' t=' in auth
|
||||
assert ',k=' in auth
|
||||
|
||||
def test_integration(self):
|
||||
# These values were taken from a test page. DO NOT ALTER!
|
||||
key = ("BDd3_hVL9fZi9Ybo2UUzA284WG5FZR30_95YeZJsiApwXKpNcF1rRPF3foI"
|
||||
"iBHXRdJI2Qhumhf6_LFTeZaNndIo")
|
||||
auth = ("eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJhdWQiOiJod"
|
||||
"HRwczovL3VwZGF0ZXMucHVzaC5zZXJ2aWNlcy5tb3ppbGxhLmNvbSIsImV"
|
||||
"4cCI6MTQ5NDY3MTQ3MCwic3ViIjoibWFpbHRvOnNpbXBsZS1wdXNoLWRlb"
|
||||
"W9AZ2F1bnRmYWNlLmNvLnVrIn0.LqPi86T-HJ71TXHAYFptZEHD7Wlfjcc"
|
||||
"4u5jYZ17WpqOlqDcW-5Wtx3x1OgYX19alhJ9oLumlS2VzEvNioZolQA")
|
||||
assert Vapid01.verify(key=key, auth="webpush {}".format(auth))
|
||||
assert Vapid02.verify(auth="vapid t={},k={}".format(auth, key))
|
||||
|
||||
def test_bad_integration(self):
|
||||
# These values were taken from a test page. DO NOT ALTER!
|
||||
key = ("BDd3_hVL9fZi9Ybo2UUzA284WG5FZR30_95YeZJsiApwXKpNcF1rRPF3foI"
|
||||
"iBHXRdJI2Qhumhf6_LFTeZaNndIo")
|
||||
auth = ("WebPush eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJhdWQiOiJod"
|
||||
"HRwczovL3VwZGF0ZXMucHVzaC5zZXJ2aWNlcy5tb3ppbGxhLmNvbSIsImV"
|
||||
"4cCI6MTQ5NDY3MTQ3MCwic3ViIjoibWFpbHRvOnNpbXBsZS1wdXNoLWRlb"
|
||||
"W9AZ2F1bnRmYWNlLmNvLnVrIn0.LqPi86T-HJ71TXHAYFptZEHD7Wlfjcc"
|
||||
"4u5jYZ17WpqOlqDcW-5Wtx3x1OgYX19alhJ9oLumlS2VzEvNioZ_BAD")
|
||||
assert not Vapid01.verify(key=key, auth=auth)
|
||||
|
||||
def test_bad_sign(self):
|
||||
v = Vapid01.from_file("/tmp/private")
|
||||
self.assertRaises(VapidException,
|
||||
v.sign,
|
||||
{})
|
||||
self.assertRaises(VapidException,
|
||||
v.sign,
|
||||
{'sub': 'foo',
|
||||
'aud': "p.example.com"})
|
||||
self.assertRaises(VapidException,
|
||||
v.sign,
|
||||
{'sub': 'mailto:foo@bar.com',
|
||||
'aud': "p.example.com"})
|
||||
self.assertRaises(VapidException,
|
||||
v.sign,
|
||||
{'sub': 'mailto:foo@bar.com',
|
||||
'aud': "https://p.example.com:8080/"})
|
||||
|
||||
def test_ignore_sub(self):
|
||||
v = Vapid02.from_file("/tmp/private")
|
||||
v.conf['no-strict'] = True
|
||||
assert v.sign({"sub": "foo", "aud": "http://localhost:8000"})
|
||||
|
||||
@patch('cryptography.hazmat.primitives.asymmetric'
|
||||
'.ec.EllipticCurvePublicNumbers')
|
||||
def test_invalid_sig(self, mm):
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
ve = Mock()
|
||||
ve.verify.side_effect = InvalidSignature
|
||||
pk = Mock()
|
||||
pk.public_key.return_value = ve
|
||||
mm.from_encoded_point.return_value = pk
|
||||
self.assertRaises(InvalidSignature,
|
||||
decode,
|
||||
'foo.bar.blat',
|
||||
'aaaa')
|
||||
self.assertRaises(InvalidSignature,
|
||||
decode,
|
||||
'foo.bar.a',
|
||||
'aaaa')
|
||||
|
||||
def test_sub(self):
|
||||
valid = [
|
||||
'mailto:me@localhost',
|
||||
'mailto:me@1.2.3.4',
|
||||
'mailto:me@1234::',
|
||||
'mailto:me@1234::5678',
|
||||
'mailto:admin@example.org',
|
||||
'mailto:admin-test-case@example-test-case.test.org',
|
||||
'https://localhost',
|
||||
'https://exmample-test-case.test.org',
|
||||
'https://8001::',
|
||||
'https://8001:1000:0001',
|
||||
'https://1.2.3.4'
|
||||
]
|
||||
invalid = [
|
||||
'mailto:@foobar.com',
|
||||
'mailto:example.org',
|
||||
'mailto:0123:',
|
||||
'mailto:::1234',
|
||||
'https://somehost',
|
||||
'https://xyz:123',
|
||||
]
|
||||
|
||||
for val in valid:
|
||||
assert _check_sub(val) is True
|
||||
for val in invalid:
|
||||
assert _check_sub(val) is False
|
||||
@@ -0,0 +1,39 @@
|
||||
import base64
|
||||
import binascii
|
||||
|
||||
|
||||
def b64urldecode(data):
|
||||
"""Decodes an unpadded Base64url-encoded string.
|
||||
|
||||
:param data: data bytes to decode
|
||||
:type data: bytes
|
||||
|
||||
:returns bytes
|
||||
|
||||
"""
|
||||
return base64.urlsafe_b64decode(data + b"===="[len(data) % 4:])
|
||||
|
||||
|
||||
def b64urlencode(data):
|
||||
"""Encode a byte string into a Base64url-encoded string without padding
|
||||
|
||||
:param data: data bytes to encode
|
||||
:type data: bytes
|
||||
|
||||
:returns str
|
||||
|
||||
"""
|
||||
return base64.urlsafe_b64encode(data).replace(b'=', b'').decode('utf8')
|
||||
|
||||
|
||||
def num_to_bytes(n, pad_to):
|
||||
"""Returns the byte representation of an integer, in big-endian order.
|
||||
:param n: The integer to encode.
|
||||
:type n: int
|
||||
:param pad_to: Expected length of result, zeropad if necessary.
|
||||
:type pad_to: int
|
||||
:returns bytes
|
||||
"""
|
||||
h = '%x' % n
|
||||
r = binascii.unhexlify('0' * (len(h) % 2) + h)
|
||||
return b'\x00' * (pad_to - len(r)) + r
|
||||
@@ -0,0 +1,229 @@
|
||||
Metadata-Version: 2.4
|
||||
Name: pywebpush
|
||||
Version: 2.1.2
|
||||
Summary: WebPush publication library
|
||||
Author-email: JR Conlin <src+webpusher@jrconlin.com>
|
||||
License: MPL-2.0
|
||||
Project-URL: Homepage, https://github.com/web-push-libs/pywebpush
|
||||
Keywords: webpush,vapid,notification
|
||||
Classifier: Topic :: Internet :: WWW/HTTP
|
||||
Classifier: Programming Language :: Python :: Implementation :: PyPy
|
||||
Classifier: Programming Language :: Python
|
||||
Classifier: Programming Language :: Python :: 3
|
||||
Description-Content-Type: text/markdown
|
||||
License-File: LICENSE
|
||||
Requires-Dist: aiohttp
|
||||
Requires-Dist: cryptography>=2.6.1
|
||||
Requires-Dist: http-ece>=1.1.0
|
||||
Requires-Dist: requests>=2.21.0
|
||||
Requires-Dist: six>=1.15.0
|
||||
Requires-Dist: py-vapid>=1.7.0
|
||||
Provides-Extra: dev
|
||||
Requires-Dist: black; extra == "dev"
|
||||
Requires-Dist: mock; extra == "dev"
|
||||
Requires-Dist: pytest; extra == "dev"
|
||||
Dynamic: license-file
|
||||
|
||||
# Webpush Data encryption library for Python
|
||||
|
||||
[](https://travis-ci.org/web-push-libs/pywebpush)
|
||||
[](https://requires.io/github/web-push-libs/pywebpush/requirements/?branch=main)
|
||||
|
||||
This library is available on [pypi as pywebpush](https://pypi.python.org/pypi/pywebpush).
|
||||
Source is available on [github](https://github.com/mozilla-services/pywebpush).
|
||||
Please note: This library was designated as a `Critical Project` by PyPi, it is currently
|
||||
maintained by [a single person](https://xkcd.com/2347/). I still accept PRs and Issues, but
|
||||
make of that what you will.
|
||||
|
||||
## Installation
|
||||
|
||||
To work with this repo locally, you'll need to run `python -m venv venv`.
|
||||
Then `venv/bin/pip install --editable .`
|
||||
|
||||
|
||||
## Usage
|
||||
|
||||
In the browser, the promise handler for
|
||||
[registration.pushManager.subscribe()](https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe)
|
||||
returns a
|
||||
[PushSubscription](https://developer.mozilla.org/en-US/docs/Web/API/PushSubscription)
|
||||
object. This object has a .toJSON() method that will return a JSON object that contains all the info we need to encrypt
|
||||
and push data.
|
||||
|
||||
As illustration, a `subscription_info` object may look like:
|
||||
|
||||
```json
|
||||
{
|
||||
"endpoint": "https://updates.push.services.mozilla.com/push/v1/gAA...",
|
||||
"keys": { "auth": "k8J...", "p256dh": "BOr..." }
|
||||
}
|
||||
```
|
||||
|
||||
How you send the PushSubscription data to your backend, store it
|
||||
referenced to the user who requested it, and recall it when there's
|
||||
a new push subscription update is left as an exercise for the
|
||||
reader.
|
||||
|
||||
### Sending Data using `webpush()` One Call
|
||||
|
||||
In many cases, your code will be sending a single message to many
|
||||
recipients. There's a "One Call" function which will make things
|
||||
easier.
|
||||
|
||||
```python
|
||||
from pywebpush import webpush
|
||||
|
||||
webpush(subscription_info,
|
||||
data,
|
||||
vapid_private_key="Private Key or File Path[1]",
|
||||
vapid_claims={"sub": "mailto:YourEmailAddress"})
|
||||
```
|
||||
|
||||
This will encode `data`, add the appropriate VAPID auth headers if required and send it to the push server identified
|
||||
in the `subscription_info` block.
|
||||
|
||||
##### Parameters
|
||||
|
||||
_subscription_info_ - The `dict` of the subscription info (described above).
|
||||
|
||||
_data_ - can be any serial content (string, bit array, serialized JSON, etc), but be sure that your receiving
|
||||
application is able to parse and understand it. (e.g. `data = "Mary had a little lamb."`)
|
||||
|
||||
_content_type_ - specifies the form of Encryption to use, either `'aes128gcm'` or the deprecated `'aesgcm'`. NOTE that
|
||||
not all User Agents can decrypt `'aesgcm'`, so the library defaults to the RFC 8188 standard form.
|
||||
|
||||
_vapid_claims_ - a `dict` containing the VAPID claims required for authorization (See
|
||||
[py_vapid](https://github.com/web-push-libs/vapid/tree/master/python) for more details). If `aud` is not specified,
|
||||
pywebpush will attempt to auto-fill from the `endpoint`. If `exp` is not specified or set in the past, it will be set
|
||||
to 12 hours from now. In both cases, the passed `dict` **will be mutated** after the call.
|
||||
|
||||
_vapid_private_key_ - Either a path to a VAPID EC2 private key PEM file, or a string containing the DER representation.
|
||||
(See [py_vapid](https://github.com/web-push-libs/vapid/tree/master/python) for more details.) The `private_key` may be
|
||||
a base64 encoded DER formatted private key, or the path to an OpenSSL exported private key file.
|
||||
|
||||
e.g. the output of:
|
||||
|
||||
```bash
|
||||
openssl ecparam -name prime256v1 -genkey -noout -out private_key.pem
|
||||
```
|
||||
|
||||
##### Example
|
||||
|
||||
```python
|
||||
from pywebpush import webpush, WebPushException
|
||||
|
||||
try:
|
||||
webpush(
|
||||
subscription_info={
|
||||
"endpoint": "https://push.example.com/v1/12345",
|
||||
"keys": {
|
||||
"p256dh": "0123abcde...",
|
||||
"auth": "abc123..."
|
||||
}},
|
||||
data="Mary had a little lamb, with a nice mint jelly",
|
||||
vapid_private_key="path/to/vapid_private.pem",
|
||||
vapid_claims={
|
||||
"sub": "mailto:YourNameHere@example.org",
|
||||
}
|
||||
)
|
||||
except WebPushException as ex:
|
||||
print("I'm sorry, Dave, but I can't do that: {}", repr(ex))
|
||||
# Mozilla returns additional information in the body of the response.
|
||||
if ex.response is not None and ex.response.json():
|
||||
extra = ex.response.json()
|
||||
print("Remote service replied with a {}:{}, {}",
|
||||
extra.code,
|
||||
extra.errno,
|
||||
extra.message
|
||||
)
|
||||
```
|
||||
|
||||
### Methods
|
||||
|
||||
If you expect to resend to the same recipient, or have more needs than just sending data quickly, you
|
||||
can pass just `wp = WebPusher(subscription_info)`. This will return a `WebPusher` object.
|
||||
|
||||
The following methods are available:
|
||||
|
||||
#### `.send(data, headers={}, ttl=0, gcm_key="", reg_id="", content_encoding="aes128gcm", curl=False, timeout=None)`
|
||||
|
||||
Send the data using additional parameters. On error, returns a `WebPushException`
|
||||
|
||||
##### Parameters
|
||||
|
||||
_data_ Binary string of data to send
|
||||
|
||||
_headers_ A `dict` containing any additional headers to send
|
||||
|
||||
_ttl_ Message Time To Live on Push Server waiting for the client to reconnect (in seconds)
|
||||
|
||||
_gcm_key_ Google Cloud Messaging key (if using the older GCM push system) This is the API key obtained from the Google
|
||||
Developer Console.
|
||||
|
||||
_reg_id_ Google Cloud Messaging registration ID (will be extracted from endpoint if not specified)
|
||||
|
||||
_content_encoding_ ECE content encoding type (defaults to "aes128gcm")
|
||||
|
||||
_curl_ Do not execute the POST, but return as a `curl` command. This will write the encrypted content to a local file
|
||||
named `encrpypted.data`. This command is meant to be used for debugging purposes.
|
||||
|
||||
_timeout_ timeout for requests POST query.
|
||||
See [requests documentation](http://docs.python-requests.org/en/master/user/quickstart/#timeouts).
|
||||
|
||||
##### Example
|
||||
|
||||
to send from Chrome using the old GCM mode:
|
||||
|
||||
```python
|
||||
WebPusher(subscription_info).send(data, headers, ttl, gcm_key)
|
||||
```
|
||||
|
||||
#### `.encode(data, content_encoding="aes128gcm")`
|
||||
|
||||
Encode the `data` for future use. On error, returns a `WebPushException`
|
||||
|
||||
##### Parameters
|
||||
|
||||
_data_ Binary string of data to send
|
||||
|
||||
_content_encoding_ ECE content encoding type (defaults to "aes128gcm")
|
||||
|
||||
*Note* This will return a `NoData` exception if the data is not present or empty. It is completely
|
||||
valid to send a WebPush notification with no data, but encoding is a no-op in that case. Best not
|
||||
to call it if you don't have data.
|
||||
|
||||
##### Example
|
||||
|
||||
```python
|
||||
encoded_data = WebPush(subscription_info).encode(data)
|
||||
```
|
||||
|
||||
## Stand Alone Webpush
|
||||
|
||||
If you're not really into coding your own solution, there's also a "stand-alone" `pywebpush` command in the
|
||||
./bin directory.
|
||||
|
||||
This uses two files:
|
||||
|
||||
- the _data_ file, which contains the message to send, in whatever form you like.
|
||||
- the _subscription info_ file, which contains the subscription information as JSON encoded data. This is usually returned by the Push `subscribe` method and looks something like:
|
||||
|
||||
```json
|
||||
{
|
||||
"endpoint": "https://push...",
|
||||
"keys": {
|
||||
"auth": "ab01...",
|
||||
"p256dh": "aa02..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If you're interested in just testing your applications WebPush interface, you could use the Command Line:
|
||||
|
||||
```bash
|
||||
./bin/pywebpush --data stuff_to_send.data --info subscription.info
|
||||
```
|
||||
|
||||
which will encrypt and send the contents of `stuff_to_send.data`.
|
||||
|
||||
See `./bin/pywebpush --help` for available commands and options.
|
||||
@@ -0,0 +1,11 @@
|
||||
pywebpush/__init__.py,sha256=vCF-y1o1D04pEsJhkjsl_9IKkKy3Y5FeQrmF-TX5Fx4,26945
|
||||
pywebpush/__main__.py,sha256=GjEZps3NHHD_3_g64Sb_LEQzT6uY1xWHuN3LKob010o,2869
|
||||
pywebpush/foo.py,sha256=ejmplddiH71DFZ7A2L2TW_jTBuC3H4FGRRaARWch6Po,1349
|
||||
pywebpush/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||
pywebpush/tests/test_webpush.py,sha256=xwLpJiHnytiwIpIZ91mJ6w0ad4nJp6ISNJ7J_RQNOfo,25481
|
||||
pywebpush-2.1.2.dist-info/licenses/LICENSE,sha256=HyVuytGSiAUQ6ErWBHTqt1iSGHhLmlC8fO7jTCuR8dU,16725
|
||||
pywebpush-2.1.2.dist-info/METADATA,sha256=v49HlJ8MkEdZ18ZBO7D_dRR_xk8rqwCqj-TDZofu-6c,8263
|
||||
pywebpush-2.1.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
||||
pywebpush-2.1.2.dist-info/entry_points.txt,sha256=6J8vnkUNHIR8-eKLXPVhB07sVdxFWih0_PzGXmtH2Jc,54
|
||||
pywebpush-2.1.2.dist-info/top_level.txt,sha256=bBvdlXyyUMP2V1KRUcbbLWC_ogM0IzDb2GVxIWDSjEc,10
|
||||
pywebpush-2.1.2.dist-info/RECORD,,
|
||||
@@ -0,0 +1,5 @@
|
||||
Wheel-Version: 1.0
|
||||
Generator: setuptools (80.9.0)
|
||||
Root-Is-Purelib: true
|
||||
Tag: py3-none-any
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
[console_scripts]
|
||||
pywebpush = pywebpush.__main__:main
|
||||
@@ -0,0 +1,373 @@
|
||||
Mozilla Public License Version 2.0
|
||||
==================================
|
||||
|
||||
1. Definitions
|
||||
--------------
|
||||
|
||||
1.1. "Contributor"
|
||||
means each individual or legal entity that creates, contributes to
|
||||
the creation of, or owns Covered Software.
|
||||
|
||||
1.2. "Contributor Version"
|
||||
means the combination of the Contributions of others (if any) used
|
||||
by a Contributor and that particular Contributor's Contribution.
|
||||
|
||||
1.3. "Contribution"
|
||||
means Covered Software of a particular Contributor.
|
||||
|
||||
1.4. "Covered Software"
|
||||
means Source Code Form to which the initial Contributor has attached
|
||||
the notice in Exhibit A, the Executable Form of such Source Code
|
||||
Form, and Modifications of such Source Code Form, in each case
|
||||
including portions thereof.
|
||||
|
||||
1.5. "Incompatible With Secondary Licenses"
|
||||
means
|
||||
|
||||
(a) that the initial Contributor has attached the notice described
|
||||
in Exhibit B to the Covered Software; or
|
||||
|
||||
(b) that the Covered Software was made available under the terms of
|
||||
version 1.1 or earlier of the License, but not also under the
|
||||
terms of a Secondary License.
|
||||
|
||||
1.6. "Executable Form"
|
||||
means any form of the work other than Source Code Form.
|
||||
|
||||
1.7. "Larger Work"
|
||||
means a work that combines Covered Software with other material, in
|
||||
a separate file or files, that is not Covered Software.
|
||||
|
||||
1.8. "License"
|
||||
means this document.
|
||||
|
||||
1.9. "Licensable"
|
||||
means having the right to grant, to the maximum extent possible,
|
||||
whether at the time of the initial grant or subsequently, any and
|
||||
all of the rights conveyed by this License.
|
||||
|
||||
1.10. "Modifications"
|
||||
means any of the following:
|
||||
|
||||
(a) any file in Source Code Form that results from an addition to,
|
||||
deletion from, or modification of the contents of Covered
|
||||
Software; or
|
||||
|
||||
(b) any new file in Source Code Form that contains any Covered
|
||||
Software.
|
||||
|
||||
1.11. "Patent Claims" of a Contributor
|
||||
means any patent claim(s), including without limitation, method,
|
||||
process, and apparatus claims, in any patent Licensable by such
|
||||
Contributor that would be infringed, but for the grant of the
|
||||
License, by the making, using, selling, offering for sale, having
|
||||
made, import, or transfer of either its Contributions or its
|
||||
Contributor Version.
|
||||
|
||||
1.12. "Secondary License"
|
||||
means either the GNU General Public License, Version 2.0, the GNU
|
||||
Lesser General Public License, Version 2.1, the GNU Affero General
|
||||
Public License, Version 3.0, or any later versions of those
|
||||
licenses.
|
||||
|
||||
1.13. "Source Code Form"
|
||||
means the form of the work preferred for making modifications.
|
||||
|
||||
1.14. "You" (or "Your")
|
||||
means an individual or a legal entity exercising rights under this
|
||||
License. For legal entities, "You" includes any entity that
|
||||
controls, is controlled by, or is under common control with You. For
|
||||
purposes of this definition, "control" means (a) the power, direct
|
||||
or indirect, to cause the direction or management of such entity,
|
||||
whether by contract or otherwise, or (b) ownership of more than
|
||||
fifty percent (50%) of the outstanding shares or beneficial
|
||||
ownership of such entity.
|
||||
|
||||
2. License Grants and Conditions
|
||||
--------------------------------
|
||||
|
||||
2.1. Grants
|
||||
|
||||
Each Contributor hereby grants You a world-wide, royalty-free,
|
||||
non-exclusive license:
|
||||
|
||||
(a) under intellectual property rights (other than patent or trademark)
|
||||
Licensable by such Contributor to use, reproduce, make available,
|
||||
modify, display, perform, distribute, and otherwise exploit its
|
||||
Contributions, either on an unmodified basis, with Modifications, or
|
||||
as part of a Larger Work; and
|
||||
|
||||
(b) under Patent Claims of such Contributor to make, use, sell, offer
|
||||
for sale, have made, import, and otherwise transfer either its
|
||||
Contributions or its Contributor Version.
|
||||
|
||||
2.2. Effective Date
|
||||
|
||||
The licenses granted in Section 2.1 with respect to any Contribution
|
||||
become effective for each Contribution on the date the Contributor first
|
||||
distributes such Contribution.
|
||||
|
||||
2.3. Limitations on Grant Scope
|
||||
|
||||
The licenses granted in this Section 2 are the only rights granted under
|
||||
this License. No additional rights or licenses will be implied from the
|
||||
distribution or licensing of Covered Software under this License.
|
||||
Notwithstanding Section 2.1(b) above, no patent license is granted by a
|
||||
Contributor:
|
||||
|
||||
(a) for any code that a Contributor has removed from Covered Software;
|
||||
or
|
||||
|
||||
(b) for infringements caused by: (i) Your and any other third party's
|
||||
modifications of Covered Software, or (ii) the combination of its
|
||||
Contributions with other software (except as part of its Contributor
|
||||
Version); or
|
||||
|
||||
(c) under Patent Claims infringed by Covered Software in the absence of
|
||||
its Contributions.
|
||||
|
||||
This License does not grant any rights in the trademarks, service marks,
|
||||
or logos of any Contributor (except as may be necessary to comply with
|
||||
the notice requirements in Section 3.4).
|
||||
|
||||
2.4. Subsequent Licenses
|
||||
|
||||
No Contributor makes additional grants as a result of Your choice to
|
||||
distribute the Covered Software under a subsequent version of this
|
||||
License (see Section 10.2) or under the terms of a Secondary License (if
|
||||
permitted under the terms of Section 3.3).
|
||||
|
||||
2.5. Representation
|
||||
|
||||
Each Contributor represents that the Contributor believes its
|
||||
Contributions are its original creation(s) or it has sufficient rights
|
||||
to grant the rights to its Contributions conveyed by this License.
|
||||
|
||||
2.6. Fair Use
|
||||
|
||||
This License is not intended to limit any rights You have under
|
||||
applicable copyright doctrines of fair use, fair dealing, or other
|
||||
equivalents.
|
||||
|
||||
2.7. Conditions
|
||||
|
||||
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
|
||||
in Section 2.1.
|
||||
|
||||
3. Responsibilities
|
||||
-------------------
|
||||
|
||||
3.1. Distribution of Source Form
|
||||
|
||||
All distribution of Covered Software in Source Code Form, including any
|
||||
Modifications that You create or to which You contribute, must be under
|
||||
the terms of this License. You must inform recipients that the Source
|
||||
Code Form of the Covered Software is governed by the terms of this
|
||||
License, and how they can obtain a copy of this License. You may not
|
||||
attempt to alter or restrict the recipients' rights in the Source Code
|
||||
Form.
|
||||
|
||||
3.2. Distribution of Executable Form
|
||||
|
||||
If You distribute Covered Software in Executable Form then:
|
||||
|
||||
(a) such Covered Software must also be made available in Source Code
|
||||
Form, as described in Section 3.1, and You must inform recipients of
|
||||
the Executable Form how they can obtain a copy of such Source Code
|
||||
Form by reasonable means in a timely manner, at a charge no more
|
||||
than the cost of distribution to the recipient; and
|
||||
|
||||
(b) You may distribute such Executable Form under the terms of this
|
||||
License, or sublicense it under different terms, provided that the
|
||||
license for the Executable Form does not attempt to limit or alter
|
||||
the recipients' rights in the Source Code Form under this License.
|
||||
|
||||
3.3. Distribution of a Larger Work
|
||||
|
||||
You may create and distribute a Larger Work under terms of Your choice,
|
||||
provided that You also comply with the requirements of this License for
|
||||
the Covered Software. If the Larger Work is a combination of Covered
|
||||
Software with a work governed by one or more Secondary Licenses, and the
|
||||
Covered Software is not Incompatible With Secondary Licenses, this
|
||||
License permits You to additionally distribute such Covered Software
|
||||
under the terms of such Secondary License(s), so that the recipient of
|
||||
the Larger Work may, at their option, further distribute the Covered
|
||||
Software under the terms of either this License or such Secondary
|
||||
License(s).
|
||||
|
||||
3.4. Notices
|
||||
|
||||
You may not remove or alter the substance of any license notices
|
||||
(including copyright notices, patent notices, disclaimers of warranty,
|
||||
or limitations of liability) contained within the Source Code Form of
|
||||
the Covered Software, except that You may alter any license notices to
|
||||
the extent required to remedy known factual inaccuracies.
|
||||
|
||||
3.5. Application of Additional Terms
|
||||
|
||||
You may choose to offer, and to charge a fee for, warranty, support,
|
||||
indemnity or liability obligations to one or more recipients of Covered
|
||||
Software. However, You may do so only on Your own behalf, and not on
|
||||
behalf of any Contributor. You must make it absolutely clear that any
|
||||
such warranty, support, indemnity, or liability obligation is offered by
|
||||
You alone, and You hereby agree to indemnify every Contributor for any
|
||||
liability incurred by such Contributor as a result of warranty, support,
|
||||
indemnity or liability terms You offer. You may include additional
|
||||
disclaimers of warranty and limitations of liability specific to any
|
||||
jurisdiction.
|
||||
|
||||
4. Inability to Comply Due to Statute or Regulation
|
||||
---------------------------------------------------
|
||||
|
||||
If it is impossible for You to comply with any of the terms of this
|
||||
License with respect to some or all of the Covered Software due to
|
||||
statute, judicial order, or regulation then You must: (a) comply with
|
||||
the terms of this License to the maximum extent possible; and (b)
|
||||
describe the limitations and the code they affect. Such description must
|
||||
be placed in a text file included with all distributions of the Covered
|
||||
Software under this License. Except to the extent prohibited by statute
|
||||
or regulation, such description must be sufficiently detailed for a
|
||||
recipient of ordinary skill to be able to understand it.
|
||||
|
||||
5. Termination
|
||||
--------------
|
||||
|
||||
5.1. The rights granted under this License will terminate automatically
|
||||
if You fail to comply with any of its terms. However, if You become
|
||||
compliant, then the rights granted under this License from a particular
|
||||
Contributor are reinstated (a) provisionally, unless and until such
|
||||
Contributor explicitly and finally terminates Your grants, and (b) on an
|
||||
ongoing basis, if such Contributor fails to notify You of the
|
||||
non-compliance by some reasonable means prior to 60 days after You have
|
||||
come back into compliance. Moreover, Your grants from a particular
|
||||
Contributor are reinstated on an ongoing basis if such Contributor
|
||||
notifies You of the non-compliance by some reasonable means, this is the
|
||||
first time You have received notice of non-compliance with this License
|
||||
from such Contributor, and You become compliant prior to 30 days after
|
||||
Your receipt of the notice.
|
||||
|
||||
5.2. If You initiate litigation against any entity by asserting a patent
|
||||
infringement claim (excluding declaratory judgment actions,
|
||||
counter-claims, and cross-claims) alleging that a Contributor Version
|
||||
directly or indirectly infringes any patent, then the rights granted to
|
||||
You by any and all Contributors for the Covered Software under Section
|
||||
2.1 of this License shall terminate.
|
||||
|
||||
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
|
||||
end user license agreements (excluding distributors and resellers) which
|
||||
have been validly granted by You or Your distributors under this License
|
||||
prior to termination shall survive termination.
|
||||
|
||||
************************************************************************
|
||||
* *
|
||||
* 6. Disclaimer of Warranty *
|
||||
* ------------------------- *
|
||||
* *
|
||||
* Covered Software is provided under this License on an "as is" *
|
||||
* basis, without warranty of any kind, either expressed, implied, or *
|
||||
* statutory, including, without limitation, warranties that the *
|
||||
* Covered Software is free of defects, merchantable, fit for a *
|
||||
* particular purpose or non-infringing. The entire risk as to the *
|
||||
* quality and performance of the Covered Software is with You. *
|
||||
* Should any Covered Software prove defective in any respect, You *
|
||||
* (not any Contributor) assume the cost of any necessary servicing, *
|
||||
* repair, or correction. This disclaimer of warranty constitutes an *
|
||||
* essential part of this License. No use of any Covered Software is *
|
||||
* authorized under this License except under this disclaimer. *
|
||||
* *
|
||||
************************************************************************
|
||||
|
||||
************************************************************************
|
||||
* *
|
||||
* 7. Limitation of Liability *
|
||||
* -------------------------- *
|
||||
* *
|
||||
* Under no circumstances and under no legal theory, whether tort *
|
||||
* (including negligence), contract, or otherwise, shall any *
|
||||
* Contributor, or anyone who distributes Covered Software as *
|
||||
* permitted above, be liable to You for any direct, indirect, *
|
||||
* special, incidental, or consequential damages of any character *
|
||||
* including, without limitation, damages for lost profits, loss of *
|
||||
* goodwill, work stoppage, computer failure or malfunction, or any *
|
||||
* and all other commercial damages or losses, even if such party *
|
||||
* shall have been informed of the possibility of such damages. This *
|
||||
* limitation of liability shall not apply to liability for death or *
|
||||
* personal injury resulting from such party's negligence to the *
|
||||
* extent applicable law prohibits such limitation. Some *
|
||||
* jurisdictions do not allow the exclusion or limitation of *
|
||||
* incidental or consequential damages, so this exclusion and *
|
||||
* limitation may not apply to You. *
|
||||
* *
|
||||
************************************************************************
|
||||
|
||||
8. Litigation
|
||||
-------------
|
||||
|
||||
Any litigation relating to this License may be brought only in the
|
||||
courts of a jurisdiction where the defendant maintains its principal
|
||||
place of business and such litigation shall be governed by laws of that
|
||||
jurisdiction, without reference to its conflict-of-law provisions.
|
||||
Nothing in this Section shall prevent a party's ability to bring
|
||||
cross-claims or counter-claims.
|
||||
|
||||
9. Miscellaneous
|
||||
----------------
|
||||
|
||||
This License represents the complete agreement concerning the subject
|
||||
matter hereof. If any provision of this License is held to be
|
||||
unenforceable, such provision shall be reformed only to the extent
|
||||
necessary to make it enforceable. Any law or regulation which provides
|
||||
that the language of a contract shall be construed against the drafter
|
||||
shall not be used to construe this License against a Contributor.
|
||||
|
||||
10. Versions of the License
|
||||
---------------------------
|
||||
|
||||
10.1. New Versions
|
||||
|
||||
Mozilla Foundation is the license steward. Except as provided in Section
|
||||
10.3, no one other than the license steward has the right to modify or
|
||||
publish new versions of this License. Each version will be given a
|
||||
distinguishing version number.
|
||||
|
||||
10.2. Effect of New Versions
|
||||
|
||||
You may distribute the Covered Software under the terms of the version
|
||||
of the License under which You originally received the Covered Software,
|
||||
or under the terms of any subsequent version published by the license
|
||||
steward.
|
||||
|
||||
10.3. Modified Versions
|
||||
|
||||
If you create software not governed by this License, and you want to
|
||||
create a new license for such software, you may create and use a
|
||||
modified version of this License if you rename the license and remove
|
||||
any references to the name of the license steward (except to note that
|
||||
such modified license differs from this License).
|
||||
|
||||
10.4. Distributing Source Code Form that is Incompatible With Secondary
|
||||
Licenses
|
||||
|
||||
If You choose to distribute Source Code Form that is Incompatible With
|
||||
Secondary Licenses under the terms of this version of the License, the
|
||||
notice described in Exhibit B of this License must be attached.
|
||||
|
||||
Exhibit A - Source Code Form License Notice
|
||||
-------------------------------------------
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
|
||||
If it is not possible or desirable to put the notice in a particular
|
||||
file, then You may include the notice in a location (such as a LICENSE
|
||||
file in a relevant directory) where a recipient would be likely to look
|
||||
for such a notice.
|
||||
|
||||
You may add additional accurate notices of copyright ownership.
|
||||
|
||||
Exhibit B - "Incompatible With Secondary Licenses" Notice
|
||||
---------------------------------------------------------
|
||||
|
||||
This Source Code Form is "Incompatible With Secondary Licenses", as
|
||||
defined by the Mozilla Public License, v. 2.0.
|
||||
@@ -0,0 +1 @@
|
||||
pywebpush
|
||||
@@ -0,0 +1,726 @@
|
||||
# This Source Code Form is subject to the terms of the Mozilla Public
|
||||
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
from copy import deepcopy
|
||||
from typing import cast, Union, Dict
|
||||
|
||||
try:
|
||||
from urlparse import urlparse
|
||||
except ImportError: # pragma nocover
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import aiohttp
|
||||
import http_ece
|
||||
import requests
|
||||
import six
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from functools import partial
|
||||
from py_vapid import Vapid, Vapid01
|
||||
from requests import Response
|
||||
|
||||
|
||||
class WebPushException(Exception):
|
||||
"""Web Push failure.
|
||||
|
||||
This may contain the requests.Response
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, message, response=None):
|
||||
self.message = message
|
||||
self.response = response
|
||||
|
||||
def __str__(self):
|
||||
extra = ""
|
||||
if self.response is not None:
|
||||
try:
|
||||
extra = ", Response {}".format(
|
||||
self.response.text,
|
||||
)
|
||||
except AttributeError:
|
||||
extra = ", Response {}".format(self.response)
|
||||
return "WebPushException: {}{}".format(self.message, extra)
|
||||
|
||||
|
||||
class NoData(Exception):
|
||||
"""Message contained No Data, no encoding required."""
|
||||
|
||||
|
||||
class CaseInsensitiveDict(dict):
|
||||
"""A dictionary that has case-insensitive keys"""
|
||||
|
||||
def __init__(self, data={}, **kwargs):
|
||||
for key in data:
|
||||
dict.__setitem__(self, key.lower(), data[key])
|
||||
self.update(kwargs)
|
||||
|
||||
def __contains__(self, key):
|
||||
return dict.__contains__(self, key.lower())
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
dict.__setitem__(self, key.lower(), value)
|
||||
|
||||
def __getitem__(self, key):
|
||||
return dict.__getitem__(self, key.lower())
|
||||
|
||||
def __delitem__(self, key):
|
||||
dict.__delitem__(self, key.lower())
|
||||
|
||||
def get(self, key, default=None):
|
||||
try:
|
||||
return self.__getitem__(key)
|
||||
except KeyError:
|
||||
return default
|
||||
|
||||
def update(self, data):
|
||||
for key in data:
|
||||
self.__setitem__(key, data[key])
|
||||
|
||||
|
||||
class WebPusher:
|
||||
"""WebPusher encrypts a data block using HTTP Encrypted Content Encoding
|
||||
for WebPush.
|
||||
|
||||
See https://tools.ietf.org/html/draft-ietf-webpush-protocol-04
|
||||
for the current specification, and
|
||||
https://developer.mozilla.org/en-US/docs/Web/API/Push_API for an
|
||||
overview of Web Push.
|
||||
|
||||
Example of use:
|
||||
|
||||
The javascript promise handler for PushManager.subscribe()
|
||||
receives a subscription_info object. subscription_info.getJSON()
|
||||
will return a JSON representation.
|
||||
(e.g.
|
||||
.. code-block:: javascript
|
||||
subscription_info.getJSON() ==
|
||||
{"endpoint": "https://push.server.com/...",
|
||||
"keys":{"auth": "...", "p256dh": "..."}
|
||||
}
|
||||
)
|
||||
|
||||
This subscription_info block can be stored.
|
||||
|
||||
To send a subscription update:
|
||||
|
||||
.. code-block:: python
|
||||
# Optional
|
||||
# headers = py_vapid.sign({"aud": "https://push.server.com/",
|
||||
"sub": "mailto:your_admin@your.site.com"})
|
||||
data = "Mary had a little lamb, with a nice mint jelly"
|
||||
WebPusher(subscription_info).send(data, headers)
|
||||
|
||||
"""
|
||||
|
||||
subscription_info = {}
|
||||
valid_encodings = [
|
||||
# "aesgcm128", # this is draft-0, but DO NOT USE.
|
||||
"aesgcm", # draft-httpbis-encryption-encoding-01
|
||||
"aes128gcm", # RFC8188 Standard encoding
|
||||
]
|
||||
verbose = False
|
||||
|
||||
# Note: the type declarations are not valid under python 3.8,
|
||||
def __init__(
|
||||
self,
|
||||
subscription_info: Dict[
|
||||
str, Union[Union[str, bytes], Dict[str, Union[str, bytes]]]
|
||||
],
|
||||
requests_session: Union[None, requests.Session] = None,
|
||||
aiohttp_session: Union[None, aiohttp.client.ClientSession] = None,
|
||||
verbose: bool = False,
|
||||
):
|
||||
"""Initialize using the info provided by the client PushSubscription
|
||||
object (See
|
||||
https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe)
|
||||
|
||||
:param subscription_info: a dict containing the subscription_info from
|
||||
the client.
|
||||
:type subscription_info: dict
|
||||
|
||||
:param requests_session: a requests.Session object to optimize requests
|
||||
to the same client.
|
||||
:type requests_session: requests.Session
|
||||
|
||||
:param verbose: provide verbose feedback
|
||||
:type verbose: bool
|
||||
|
||||
"""
|
||||
|
||||
self.verbose = verbose
|
||||
if requests_session is None:
|
||||
self.requests_method = requests
|
||||
else:
|
||||
self.requests_method = requests_session
|
||||
|
||||
self.aiohttp_session = aiohttp_session
|
||||
|
||||
if "endpoint" not in subscription_info:
|
||||
raise WebPushException("subscription_info missing endpoint URL")
|
||||
self.subscription_info = deepcopy(subscription_info)
|
||||
self.auth_key = self.receiver_key = None
|
||||
if "keys" in subscription_info:
|
||||
keys: Dict[str, Union[str, bytes]] = cast(
|
||||
Dict[str, Union[str, bytes]], self.subscription_info["keys"]
|
||||
)
|
||||
for k in ["p256dh", "auth"]:
|
||||
if keys.get(k) is None:
|
||||
raise WebPushException("Missing keys value: {}".format(k))
|
||||
if isinstance(keys[k], six.text_type):
|
||||
keys[k] = bytes(cast(str, keys[k]).encode("utf8"))
|
||||
receiver_raw = base64.urlsafe_b64decode(
|
||||
self._repad(cast(bytes, keys["p256dh"]))
|
||||
)
|
||||
if len(receiver_raw) != 65 and receiver_raw[0] != "\x04":
|
||||
raise WebPushException("Invalid p256dh key specified")
|
||||
self.receiver_key = receiver_raw
|
||||
self.auth_key = base64.urlsafe_b64decode(
|
||||
self._repad(cast(bytes, keys["auth"]))
|
||||
)
|
||||
|
||||
def verb(self, msg: str, *args, **kwargs):
|
||||
if self.verbose:
|
||||
logging.info(msg.format(*args, **kwargs))
|
||||
|
||||
def _repad(self, data: bytes):
|
||||
"""Add base64 padding to the end of a string, if required"""
|
||||
return data + b"===="[: len(data) % 4]
|
||||
|
||||
def encode(
|
||||
self, data: bytes, content_encoding: str = "aes128gcm"
|
||||
) -> CaseInsensitiveDict:
|
||||
"""Encrypt the data.
|
||||
|
||||
:param data: A serialized block of byte data (String, JSON, bit array,
|
||||
etc.) Make sure that whatever you send, your client knows how
|
||||
to understand it.
|
||||
:type data: str
|
||||
:param content_encoding: The content_encoding type to use to encrypt
|
||||
the data. Defaults to RFC8188 "aes128gcm". The previous draft-01 is
|
||||
"aesgcm", however this format is now deprecated.
|
||||
:type content_encoding: enum("aesgcm", "aes128gcm")
|
||||
|
||||
"""
|
||||
reply = CaseInsensitiveDict()
|
||||
# Salt is a random 16 byte array.
|
||||
if not data:
|
||||
self.verb("No data found...")
|
||||
raise NoData()
|
||||
if not self.auth_key or not self.receiver_key:
|
||||
raise WebPushException("No keys specified in subscription info")
|
||||
self.verb("Encoding data...")
|
||||
salt = None
|
||||
if content_encoding not in self.valid_encodings:
|
||||
raise WebPushException(
|
||||
"Invalid content encoding specified. "
|
||||
"Select from " + json.dumps(self.valid_encodings)
|
||||
)
|
||||
if content_encoding == "aesgcm":
|
||||
self.verb("Generating salt for aesgcm...")
|
||||
salt = os.urandom(16)
|
||||
logging.debug("Salt: {}".format(salt))
|
||||
# The server key is an ephemeral ECDH key used only for this
|
||||
# transaction
|
||||
server_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
|
||||
crypto_key = server_key.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.X962,
|
||||
format=serialization.PublicFormat.UncompressedPoint,
|
||||
)
|
||||
|
||||
if isinstance(data, six.text_type):
|
||||
data = bytes(data.encode("utf8"))
|
||||
if content_encoding == "aes128gcm":
|
||||
self.verb("Encrypting to aes128gcm...")
|
||||
encrypted = http_ece.encrypt(
|
||||
data,
|
||||
salt=salt,
|
||||
private_key=server_key,
|
||||
dh=self.receiver_key,
|
||||
auth_secret=self.auth_key,
|
||||
version=content_encoding,
|
||||
)
|
||||
reply["body"] = encrypted
|
||||
else:
|
||||
self.verb("Encrypting to aesgcm...")
|
||||
crypto_key = base64.urlsafe_b64encode(crypto_key).strip(b"=")
|
||||
encrypted = http_ece.encrypt(
|
||||
data,
|
||||
salt=salt,
|
||||
private_key=server_key,
|
||||
keyid=crypto_key.decode(),
|
||||
dh=self.receiver_key,
|
||||
auth_secret=self.auth_key,
|
||||
version=content_encoding,
|
||||
)
|
||||
reply["crypto_key"] = crypto_key
|
||||
reply["body"] = encrypted
|
||||
if salt:
|
||||
reply["salt"] = base64.urlsafe_b64encode(salt).strip(b"=")
|
||||
return reply
|
||||
|
||||
def as_curl(self, endpoint: str, encoded_data: bytes, headers: Dict[str, str]):
|
||||
"""Return the send as a curl command.
|
||||
|
||||
Useful for debugging. This will write out the encoded data to a local
|
||||
file named `encrypted.data`
|
||||
|
||||
:param endpoint: Push service endpoint URL
|
||||
:type endpoint: basestring
|
||||
:param encoded_data: byte array of encoded data
|
||||
:type encoded_data: bytearray
|
||||
:param headers: Additional headers for the send
|
||||
:type headers: dict
|
||||
:returns string
|
||||
|
||||
"""
|
||||
header_list = [
|
||||
'-H "{}: {}" \\ \n'.format(key.lower(), val) for key, val in headers.items()
|
||||
]
|
||||
data = ""
|
||||
if encoded_data:
|
||||
with open("encrypted.data", "wb") as f:
|
||||
f.write(encoded_data)
|
||||
data = "--data-binary @encrypted.data"
|
||||
if "content-length" not in headers:
|
||||
self.verb("Generating content-length header...")
|
||||
header_list.append(
|
||||
'-H "content-length: {}" \\ \n'.format(len(encoded_data))
|
||||
)
|
||||
return """curl -vX POST {url} \\\n{headers}{data}""".format(
|
||||
url=endpoint, headers="".join(header_list), data=data
|
||||
)
|
||||
|
||||
def _prepare_send_data(
|
||||
self,
|
||||
data: Union[None, bytes] = None,
|
||||
headers: Union[None, Dict[str, str]] = None,
|
||||
ttl: int = 0,
|
||||
gcm_key: Union[None, str] = None,
|
||||
reg_id: Union[None, str] = None,
|
||||
content_encoding: str = "aes128gcm",
|
||||
curl: bool = False,
|
||||
) -> dict:
|
||||
"""Encode and send the data to the Push Service.
|
||||
|
||||
:param data: A serialized block of data (see encode() ).
|
||||
:type data: str
|
||||
:param headers: A dictionary containing any additional HTTP headers.
|
||||
:type headers: dict
|
||||
:param ttl: The Time To Live in seconds for this message if the
|
||||
recipient is not online. (Defaults to "0", which discards the
|
||||
message immediately if the recipient is unavailable.)
|
||||
:type ttl: int
|
||||
:param gcm_key: API key obtained from the Google Developer Console.
|
||||
Needed if endpoint is https://android.googleapis.com/gcm/send
|
||||
:type gcm_key: string
|
||||
:param reg_id: registration id of the recipient. If not provided,
|
||||
it will be extracted from the endpoint.
|
||||
:type reg_id: str
|
||||
:param content_encoding: ECE content encoding (defaults to "aes128gcm")
|
||||
:type content_encoding: str
|
||||
:param curl: Display output as `curl` command instead of sending
|
||||
:type curl: bool
|
||||
"""
|
||||
# Encode the data.
|
||||
if headers is None:
|
||||
headers = dict()
|
||||
encoded = CaseInsensitiveDict()
|
||||
headers = CaseInsensitiveDict(headers)
|
||||
if data:
|
||||
encoded = self.encode(data, content_encoding)
|
||||
if "crypto_key" in encoded:
|
||||
# Append the p256dh to the end of any existing crypto-key
|
||||
crypto_key = headers.get("crypto-key", "")
|
||||
if crypto_key:
|
||||
# due to some confusion by a push service provider, we
|
||||
# should use ';' instead of ',' to append the headers.
|
||||
# see
|
||||
# https://github.com/webpush-wg/webpush-encryption/issues/6
|
||||
crypto_key += ";"
|
||||
crypto_key += "dh=" + encoded["crypto_key"].decode("utf8")
|
||||
headers.update({"crypto-key": crypto_key})
|
||||
if "salt" in encoded:
|
||||
headers.update({"encryption": "salt=" + encoded["salt"].decode("utf8")})
|
||||
headers.update(
|
||||
{
|
||||
"content-encoding": content_encoding,
|
||||
}
|
||||
)
|
||||
if gcm_key:
|
||||
# guess if it is a legacy GCM project key or actual FCM key
|
||||
# gcm keys are all about 40 chars (use 100 for confidence),
|
||||
# fcm keys are 153-175 chars
|
||||
if len(gcm_key) < 100:
|
||||
self.verb("Guessing this is legacy GCM...")
|
||||
endpoint = "https://android.googleapis.com/gcm/send"
|
||||
else:
|
||||
self.verb("Guessing this is FCM...")
|
||||
endpoint = "https://fcm.googleapis.com/fcm/send"
|
||||
reg_ids = []
|
||||
if not reg_id:
|
||||
reg_id = cast(str, self.subscription_info["endpoint"]).rsplit("/", 1)[
|
||||
-1
|
||||
]
|
||||
self.verb("Fetching out registration id: {}", reg_id)
|
||||
reg_ids.append(reg_id)
|
||||
gcm_data = dict()
|
||||
gcm_data["registration_ids"] = reg_ids
|
||||
if data:
|
||||
buffer = encoded.get("body")
|
||||
if buffer:
|
||||
gcm_data["raw_data"] = base64.b64encode(buffer).decode("utf8")
|
||||
gcm_data["time_to_live"] = int(headers["ttl"] if "ttl" in headers else ttl)
|
||||
encoded_data = json.dumps(gcm_data)
|
||||
headers.update(
|
||||
{
|
||||
"Authorization": "key=" + gcm_key,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
)
|
||||
else:
|
||||
encoded_data = encoded.get("body")
|
||||
endpoint = self.subscription_info["endpoint"]
|
||||
|
||||
if "ttl" not in headers or ttl:
|
||||
self.verb("Generating TTL of 0...")
|
||||
headers["ttl"] = str(ttl or 0)
|
||||
# Additionally useful headers:
|
||||
# Authorization / Crypto-Key (VAPID headers)
|
||||
|
||||
self.verb(
|
||||
"\nSending request to" "\n\thost: {}\n\theaders: {}\n\tdata: {}",
|
||||
endpoint,
|
||||
headers,
|
||||
encoded_data,
|
||||
)
|
||||
|
||||
return {"endpoint": endpoint, "data": encoded_data, "headers": headers}
|
||||
|
||||
def send(self, *args, **kwargs) -> Union[Response, str]:
|
||||
"""Encode and send the data to the Push Service"""
|
||||
timeout = kwargs.pop("timeout", 10000)
|
||||
curl = kwargs.pop("curl", False)
|
||||
|
||||
params = self._prepare_send_data(*args, **kwargs)
|
||||
endpoint = params.pop("endpoint")
|
||||
|
||||
if curl:
|
||||
encoded_data = params["data"]
|
||||
headers = params["headers"]
|
||||
return self.as_curl(endpoint, encoded_data=encoded_data, headers=headers)
|
||||
|
||||
resp = self.requests_method.post(
|
||||
endpoint,
|
||||
timeout=timeout,
|
||||
**params,
|
||||
)
|
||||
self.verb(
|
||||
"\nResponse:\n\tcode: {}\n\tbody: {}\n",
|
||||
resp.status_code,
|
||||
resp.text or "Empty",
|
||||
)
|
||||
return resp
|
||||
|
||||
async def send_async(self, *args, **kwargs) -> Union[aiohttp.ClientResponse, str]:
|
||||
timeout = kwargs.pop("timeout", 10000)
|
||||
curl = kwargs.pop("curl", False)
|
||||
|
||||
params = self._prepare_send_data(*args, **kwargs)
|
||||
endpoint = params.pop("endpoint")
|
||||
|
||||
if curl:
|
||||
encoded_data = params["data"]
|
||||
headers = params["headers"]
|
||||
return self.as_curl(endpoint, encoded_data=encoded_data, headers=headers)
|
||||
if self.aiohttp_session:
|
||||
resp = await self.aiohttp_session.post(endpoint, timeout=timeout, **params)
|
||||
resp_text = await resp.text()
|
||||
else:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
resp = await session.post(endpoint, timeout=timeout, **params)
|
||||
resp_text = await resp.text()
|
||||
self.verb(
|
||||
"\nResponse:\n\tcode: {}\n\tbody: {}\n",
|
||||
resp.status,
|
||||
resp_text or "Empty",
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
def webpush(
|
||||
subscription_info: Dict[
|
||||
str, Union[Union[str, bytes], Dict[str, Union[str, bytes]]]
|
||||
],
|
||||
data: Union[None, str] = None,
|
||||
vapid_private_key: Union[None, Vapid, str] = None,
|
||||
vapid_claims: Union[None, Dict[str, Union[str, int]]] = None,
|
||||
content_encoding: str = "aes128gcm",
|
||||
curl: bool = False,
|
||||
timeout: Union[None, float] = None,
|
||||
ttl: int = 0,
|
||||
verbose: bool = False,
|
||||
headers: Union[None, Dict[str, Union[str, int, float]]] = None,
|
||||
requests_session: Union[None, requests.Session] = None,
|
||||
) -> Union[str, requests.Response]:
|
||||
"""
|
||||
One call solution to endcode and send `data` to the endpoint
|
||||
contained in `subscription_info` using optional VAPID auth headers.
|
||||
|
||||
in example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from pywebpush import python
|
||||
|
||||
webpush(
|
||||
subscription_info={
|
||||
"endpoint": "https://push.example.com/v1/abcd",
|
||||
"keys": {"p256dh": "0123abcd...",
|
||||
"auth": "001122..."}
|
||||
},
|
||||
data="Mary had a little lamb, with a nice mint jelly",
|
||||
vapid_private_key="path/to/key.pem",
|
||||
vapid_claims={"sub": "YourNameHere@example.com"}
|
||||
)
|
||||
|
||||
No additional method call is required. Any non-success will throw a
|
||||
`WebPushException`.
|
||||
|
||||
:param subscription_info: Provided by the client call
|
||||
:type subscription_info: dict
|
||||
:param data: Serialized data to send
|
||||
:type data: str
|
||||
:param vapid_private_key: Vapid instance or path to vapid private key PEM \
|
||||
or encoded str
|
||||
:type vapid_private_key: Union[Vapid, str]
|
||||
:param vapid_claims: Dictionary of claims ('sub' required)
|
||||
:type vapid_claims: dict
|
||||
:param content_encoding: Optional content type string
|
||||
:type content_encoding: str
|
||||
:param curl: Return as "curl" string instead of sending
|
||||
:type curl: bool
|
||||
:param timeout: POST requests timeout
|
||||
:type timeout: float
|
||||
:param ttl: Time To Live
|
||||
:type ttl: int
|
||||
:param verbose: Provide verbose feedback
|
||||
:type verbose: bool
|
||||
:return requests.Response or string
|
||||
:param headers: Dictionary of extra HTTP headers to include
|
||||
:type headers: dict
|
||||
|
||||
"""
|
||||
if headers is None:
|
||||
headers = dict()
|
||||
else:
|
||||
# Ensure we don't leak VAPID headers by mutating the passed in dict.
|
||||
headers = headers.copy()
|
||||
|
||||
vapid_headers = None
|
||||
if vapid_claims:
|
||||
if verbose:
|
||||
logging.info("Generating VAPID headers...")
|
||||
if not vapid_claims.get("aud"):
|
||||
url = urlparse(cast(str, subscription_info.get("endpoint")))
|
||||
aud = "{}://{}".format(url.scheme, url.netloc)
|
||||
vapid_claims["aud"] = aud
|
||||
# Remember, passed structures are mutable in python.
|
||||
# It's possible that a previously set `exp` field is no longer valid.
|
||||
if not vapid_claims.get("exp") or int(vapid_claims.get("exp") or 0) < int(
|
||||
time.time()
|
||||
):
|
||||
# encryption lives for 12 hours
|
||||
vapid_claims["exp"] = int(time.time()) + (12 * 60 * 60)
|
||||
if verbose:
|
||||
logging.info("Setting VAPID expry to {}...".format(vapid_claims["exp"]))
|
||||
if not vapid_private_key:
|
||||
raise WebPushException("VAPID dict missing 'private_key'")
|
||||
if isinstance(vapid_private_key, Vapid01):
|
||||
if verbose:
|
||||
logging.info("Looks like we already have a valid VAPID key")
|
||||
vv = vapid_private_key
|
||||
elif os.path.isfile(vapid_private_key):
|
||||
# Presume that key from file is handled correctly by
|
||||
# py_vapid.
|
||||
if verbose:
|
||||
logging.info("Reading VAPID key from file {}".format(vapid_private_key))
|
||||
vv = Vapid.from_file(private_key_file=vapid_private_key) # pragma no cover
|
||||
else:
|
||||
if verbose:
|
||||
logging.info("Reading VAPID key from arguments")
|
||||
vv = Vapid.from_string(private_key=vapid_private_key)
|
||||
if verbose:
|
||||
logging.info("\t claims: {}".format(vapid_claims))
|
||||
vapid_headers = vv.sign(vapid_claims)
|
||||
if verbose:
|
||||
logging.info("\t headers: {}".format(vapid_headers))
|
||||
headers.update(vapid_headers)
|
||||
|
||||
response = WebPusher(
|
||||
subscription_info, requests_session=requests_session, verbose=verbose
|
||||
).send(
|
||||
data,
|
||||
headers,
|
||||
ttl=ttl,
|
||||
content_encoding=content_encoding,
|
||||
curl=curl,
|
||||
timeout=timeout,
|
||||
)
|
||||
if not curl and cast(Response, response).status_code > 202:
|
||||
response = cast(Response, response)
|
||||
raise WebPushException(
|
||||
"Push failed: {} {}\nResponse body:{}".format(
|
||||
response.status_code, response.reason, response.text
|
||||
),
|
||||
response=response,
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
async def webpush_async(
|
||||
subscription_info: Dict[
|
||||
str, Union[Union[str, bytes], Dict[str, Union[str, bytes]]]
|
||||
],
|
||||
data: Union[None, str] = None,
|
||||
vapid_private_key: Union[None, Vapid, str] = None,
|
||||
vapid_claims: Union[None, Dict[str, Union[str, int]]] = None,
|
||||
content_encoding: str = "aes128gcm",
|
||||
curl: bool = False,
|
||||
timeout: Union[None, float] = None,
|
||||
ttl: int = 0,
|
||||
verbose: bool = False,
|
||||
headers: Union[None, Dict[str, Union[str, int, float]]] = None,
|
||||
aiohttp_session: Union[None, aiohttp.ClientSession] = None,
|
||||
) -> Union[str, aiohttp.ClientResponse]:
|
||||
"""
|
||||
Async version of webpush function. One call solution to encode and send
|
||||
`data` to the endpoint contained in `subscription_info` using optional
|
||||
VAPID auth headers.
|
||||
|
||||
Example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from pywebpush import webpush_async
|
||||
import asyncio
|
||||
|
||||
async def send_notification():
|
||||
response = await webpush_async(
|
||||
subscription_info={
|
||||
"endpoint": "https://push.example.com/v1/abcd",
|
||||
"keys": {"p256dh": "0123abcd...",
|
||||
"auth": "001122..."}
|
||||
},
|
||||
data="Mary had a little lamb, with a nice mint jelly",
|
||||
vapid_private_key="path/to/key.pem",
|
||||
vapid_claims={"sub": "YourNameHere@example.com"}
|
||||
)
|
||||
|
||||
asyncio.run(send_notification())
|
||||
|
||||
No additional method call is required. Any non-success will throw a
|
||||
`WebPushException`.
|
||||
|
||||
:param subscription_info: Provided by the client call
|
||||
:type subscription_info: dict
|
||||
:param data: Serialized data to send
|
||||
:type data: str
|
||||
:param vapid_private_key: Vapid instance or path to vapid private key PEM \
|
||||
or encoded str
|
||||
:type vapid_private_key: Union[Vapid, str]
|
||||
:param vapid_claims: Dictionary of claims ('sub' required)
|
||||
:type vapid_claims: dict
|
||||
:param content_encoding: Optional content type string
|
||||
:type content_encoding: str
|
||||
:param curl: Return as "curl" string instead of sending
|
||||
:type curl: bool
|
||||
:param timeout: POST requests timeout
|
||||
:type timeout: float
|
||||
:param ttl: Time To Live
|
||||
:type ttl: int
|
||||
:param verbose: Provide verbose feedback
|
||||
:type verbose: bool
|
||||
:param headers: Dictionary of extra HTTP headers to include
|
||||
:type headers: dict
|
||||
:param aiohttp_session: Optional aiohttp ClientSession for connection reuse
|
||||
:type aiohttp_session: aiohttp.ClientSession
|
||||
:return aiohttp.ClientResponse or string
|
||||
|
||||
"""
|
||||
if headers is None:
|
||||
headers = dict()
|
||||
else:
|
||||
# Ensure we don't leak VAPID headers by mutating the passed in dict.
|
||||
headers = headers.copy()
|
||||
|
||||
vapid_headers = None
|
||||
if vapid_claims:
|
||||
if verbose:
|
||||
logging.info("Generating VAPID headers...")
|
||||
if not vapid_claims.get("aud"):
|
||||
url = urlparse(cast(str, subscription_info.get("endpoint")))
|
||||
aud = "{}://{}".format(url.scheme, url.netloc)
|
||||
vapid_claims["aud"] = aud
|
||||
# Remember, passed structures are mutable in python.
|
||||
# It's possible that a previously set `exp` field is no longer valid.
|
||||
if not vapid_claims.get("exp") or int(vapid_claims.get("exp") or 0) < int(
|
||||
time.time()
|
||||
):
|
||||
# encryption lives for 12 hours
|
||||
vapid_claims["exp"] = int(time.time()) + (12 * 60 * 60)
|
||||
if verbose:
|
||||
logging.info(
|
||||
"Setting VAPID expiry to {}...".format(vapid_claims["exp"])
|
||||
)
|
||||
if not vapid_private_key:
|
||||
raise WebPushException("VAPID dict missing 'private_key'")
|
||||
if isinstance(vapid_private_key, Vapid01):
|
||||
if verbose:
|
||||
logging.info("Looks like we already have a valid VAPID key")
|
||||
vv = vapid_private_key
|
||||
elif os.path.isfile(vapid_private_key):
|
||||
# Presume that key from file is handled correctly by
|
||||
# py_vapid.
|
||||
if verbose:
|
||||
logging.info("Reading VAPID key from file {}".format(vapid_private_key))
|
||||
vv = Vapid.from_file(private_key_file=vapid_private_key) # pragma no cover
|
||||
else:
|
||||
if verbose:
|
||||
logging.info("Reading VAPID key from arguments")
|
||||
vv = Vapid.from_string(private_key=vapid_private_key)
|
||||
if verbose:
|
||||
logging.info("\t claims: {}".format(vapid_claims))
|
||||
vapid_headers = vv.sign(vapid_claims)
|
||||
if verbose:
|
||||
logging.info("\t headers: {}".format(vapid_headers))
|
||||
headers.update(vapid_headers)
|
||||
|
||||
response = await WebPusher(
|
||||
subscription_info, aiohttp_session=aiohttp_session, verbose=verbose
|
||||
).send_async(
|
||||
data,
|
||||
headers,
|
||||
ttl=ttl,
|
||||
content_encoding=content_encoding,
|
||||
curl=curl,
|
||||
timeout=timeout,
|
||||
)
|
||||
if not curl and cast(aiohttp.ClientResponse, response).status > 202:
|
||||
response = cast(aiohttp.ClientResponse, response)
|
||||
response_text = await response.text()
|
||||
raise WebPushException(
|
||||
"Push failed: {} {}\nResponse body:{}".format(
|
||||
response.status, response.reason, response_text
|
||||
),
|
||||
response=response,
|
||||
)
|
||||
return response
|
||||
@@ -0,0 +1,93 @@
|
||||
import argparse
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
|
||||
from requests import JSONDecodeError
|
||||
|
||||
from pywebpush import webpush, WebPushException
|
||||
|
||||
|
||||
def get_config():
|
||||
parser = argparse.ArgumentParser(description="WebPush tool")
|
||||
parser.add_argument("--data", "-d", help="Data file")
|
||||
parser.add_argument("--info", "-i", help="Subscription Info JSON file")
|
||||
parser.add_argument("--head", help="Header Info JSON file")
|
||||
parser.add_argument("--claims", help="Vapid claim file")
|
||||
parser.add_argument("--key", help="Vapid private key file path")
|
||||
parser.add_argument(
|
||||
"--curl",
|
||||
help="Don't send, display as curl command",
|
||||
default=False,
|
||||
action="store_true",
|
||||
)
|
||||
parser.add_argument("--encoding", default="aes128gcm")
|
||||
parser.add_argument(
|
||||
"--verbose",
|
||||
"-v",
|
||||
help="Provide verbose feedback",
|
||||
default=False,
|
||||
action="store_true",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.info:
|
||||
raise WebPushException("Subscription Info argument missing.")
|
||||
if not os.path.exists(args.info):
|
||||
raise WebPushException("Subscription Info file missing.")
|
||||
try:
|
||||
with open(args.info) as r:
|
||||
try:
|
||||
args.sub_info = json.loads(r.read())
|
||||
except JSONDecodeError as e:
|
||||
raise WebPushException(
|
||||
"Could not read the subscription info file: {}", e
|
||||
)
|
||||
if args.data:
|
||||
with open(args.data) as r:
|
||||
args.data = r.read()
|
||||
if args.head:
|
||||
with open(args.head) as r:
|
||||
try:
|
||||
args.head = json.loads(r.read())
|
||||
except JSONDecodeError as e:
|
||||
raise WebPushException("Could not read the header arguments: {}", e)
|
||||
if args.claims:
|
||||
if not args.key:
|
||||
raise WebPushException("No private --key specified for claims")
|
||||
with open(args.claims) as r:
|
||||
try:
|
||||
args.claims = json.loads(r.read())
|
||||
except JSONDecodeError as e:
|
||||
raise WebPushException(
|
||||
"Could not read the VAPID claims file {}".format(e)
|
||||
)
|
||||
except Exception as ex:
|
||||
logging.error("Couldn't read input {}.".format(ex))
|
||||
raise ex
|
||||
return args
|
||||
|
||||
|
||||
def main():
|
||||
"""Send data"""
|
||||
|
||||
try:
|
||||
args = get_config()
|
||||
result = webpush(
|
||||
args.sub_info,
|
||||
data=args.data,
|
||||
vapid_private_key=args.key,
|
||||
vapid_claims=args.claims,
|
||||
curl=args.curl,
|
||||
content_encoding=args.encoding,
|
||||
verbose=args.verbose,
|
||||
headers=args.head,
|
||||
)
|
||||
print(result)
|
||||
except Exception as ex:
|
||||
logging.error("{}".format(ex))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,51 @@
|
||||
from pywebpush import webpush
|
||||
import json
|
||||
import logging
|
||||
import datetime
|
||||
|
||||
|
||||
def send_push_notification(subscription, payload):
|
||||
|
||||
try:
|
||||
|
||||
# subscriptionData = json.loads(subscription)
|
||||
|
||||
# logger.error(subscriptionData)
|
||||
|
||||
webpush(
|
||||
subscription_info={
|
||||
"endpoint": subscription["endpoint"],
|
||||
"keys": subscription["keys"],
|
||||
},
|
||||
data=json.dumps(payload),
|
||||
vapid_claims={
|
||||
"aud": "https://eshopper.africa",
|
||||
"exp": int((datetime.datetime.now().timestamp())) + 86400,
|
||||
"sub": "mailto:events@eshopper.africa",
|
||||
},
|
||||
vapid_private_key="UCUKEHn7Jd33QZx5lJFKBY4plOxGsJ6xJSOzE14jQlo",
|
||||
)
|
||||
|
||||
# subscription_info = { 'endpoint': subscription['endpoint'], 'keys': subscription['keys'] },
|
||||
|
||||
# data = json.loads(payload),
|
||||
|
||||
# headers = {}
|
||||
|
||||
# ttl = 0
|
||||
|
||||
# gcm_key = ''
|
||||
|
||||
# content_encoding="aes128gcm"
|
||||
|
||||
# reg_id=""
|
||||
|
||||
# WebPusher = webpush(subscription_info)
|
||||
|
||||
# WebPusher(subscription_info).send(data, headers, ttl, gcm_key, reg_id, content_encoding, timeout=None)
|
||||
|
||||
except Exception as inst:
|
||||
print(f" webpush Notification Error : {inst}")
|
||||
|
||||
|
||||
send_push_notification({"endpoint": "https://example.com", "keys": {}}, "laaaa")
|
||||
@@ -0,0 +1,617 @@
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import unittest
|
||||
import time
|
||||
from typing import cast, Union, Dict
|
||||
from unittest.mock import patch, Mock, AsyncMock
|
||||
|
||||
import http_ece
|
||||
import py_vapid
|
||||
import requests
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
|
||||
from pywebpush import (
|
||||
WebPusher,
|
||||
NoData,
|
||||
WebPushException,
|
||||
CaseInsensitiveDict,
|
||||
webpush,
|
||||
webpush_async,
|
||||
)
|
||||
|
||||
|
||||
class WebpushTestUtils(unittest.TestCase):
|
||||
# This is a exported DER formatted string of an ECDH public key
|
||||
# This was lifted from the py_vapid tests.
|
||||
vapid_key = (
|
||||
"MHcCAQEEIPeN1iAipHbt8+/KZ2NIF8NeN24jqAmnMLFZEMocY8RboAoGCCqGSM49"
|
||||
"AwEHoUQDQgAEEJwJZq/GN8jJbo1GGpyU70hmP2hbWAUpQFKDByKB81yldJ9GTklB"
|
||||
"M5xqEwuPM7VuQcyiLDhvovthPIXx+gsQRQ=="
|
||||
)
|
||||
|
||||
def _gen_subscription_info(self, recv_key=None, endpoint="https://example.com/"):
|
||||
if not recv_key:
|
||||
recv_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
|
||||
return {
|
||||
"endpoint": endpoint,
|
||||
"keys": {
|
||||
"auth": base64.urlsafe_b64encode(os.urandom(16)).strip(b"="),
|
||||
"p256dh": self._get_pubkey_str(recv_key),
|
||||
},
|
||||
}
|
||||
|
||||
def _get_pubkey_str(self, priv_key):
|
||||
return base64.urlsafe_b64encode(
|
||||
priv_key.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.X962,
|
||||
format=serialization.PublicFormat.UncompressedPoint,
|
||||
)
|
||||
).strip(b"=")
|
||||
|
||||
def test_init(self):
|
||||
# use static values so we know what to look for in the reply
|
||||
subscription_info = {
|
||||
"endpoint": "https://example.com/",
|
||||
"keys": {
|
||||
"p256dh": (
|
||||
"BOrnIslXrUow2VAzKCUAE4sIbK00daEZCswOcf8m3T"
|
||||
"F8V82B-OpOg5JbmYLg44kRcvQC1E2gMJshsUYA-_zMPR8"
|
||||
),
|
||||
"auth": "k8JV6sjdbhAi1n3_LDBLvA",
|
||||
},
|
||||
}
|
||||
rk_decode = (
|
||||
b'\x04\xea\xe7"\xc9W\xadJ0\xd9P3(%\x00\x13\x8b'
|
||||
b"\x08l\xad4u\xa1\x19\n\xcc\x0eq\xff&\xdd1"
|
||||
b"|W\xcd\x81\xf8\xeaN\x83\x92[\x99\x82\xe0\xe3"
|
||||
b"\x89\x11r\xf4\x02\xd4M\xa00\x9b!\xb1F\x00"
|
||||
b"\xfb\xfc\xcc=\x1f"
|
||||
)
|
||||
self.assertRaises(
|
||||
WebPushException, WebPusher, {"keys": {"p256dh": "AAA=", "auth": "AAA="}}
|
||||
)
|
||||
self.assertRaises(
|
||||
WebPushException,
|
||||
WebPusher,
|
||||
{"endpoint": "https://example.com", "keys": {"p256dh": "AAA="}},
|
||||
)
|
||||
self.assertRaises(
|
||||
WebPushException,
|
||||
WebPusher,
|
||||
{"endpoint": "https://example.com", "keys": {"auth": "AAA="}},
|
||||
)
|
||||
self.assertRaises(
|
||||
WebPushException,
|
||||
WebPusher,
|
||||
{
|
||||
"endpoint": "https://example.com",
|
||||
"keys": {"p256dh": "AAA=", "auth": "AAA="},
|
||||
},
|
||||
)
|
||||
|
||||
push = WebPusher(subscription_info)
|
||||
assert push.subscription_info != subscription_info
|
||||
assert push.subscription_info["keys"] != subscription_info["keys"]
|
||||
assert push.subscription_info["endpoint"] == subscription_info["endpoint"]
|
||||
assert push.receiver_key == rk_decode
|
||||
assert push.auth_key == b'\x93\xc2U\xea\xc8\xddn\x10"\xd6}\xff,0K\xbc'
|
||||
|
||||
def test_encode(self):
|
||||
for content_encoding in ["aesgcm", "aes128gcm"]:
|
||||
recv_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
|
||||
subscription_info = self._gen_subscription_info(recv_key)
|
||||
data = "Mary had a little lamb, with some nice mint jelly"
|
||||
push = WebPusher(subscription_info)
|
||||
encoded = push.encode(data.encode(), content_encoding=content_encoding)
|
||||
"""
|
||||
crypto_key = base64.urlsafe_b64encode(
|
||||
self._get_pubkey_str(recv_key)
|
||||
).strip(b'=')
|
||||
"""
|
||||
# Convert these b64 strings into their raw, binary form.
|
||||
raw_salt = None
|
||||
if "salt" in encoded:
|
||||
raw_salt = base64.urlsafe_b64decode(push._repad(encoded["salt"]))
|
||||
raw_dh = None
|
||||
if content_encoding != "aes128gcm":
|
||||
raw_dh = base64.urlsafe_b64decode(push._repad(encoded["crypto_key"]))
|
||||
raw_auth = base64.urlsafe_b64decode(
|
||||
push._repad(subscription_info["keys"]["auth"])
|
||||
)
|
||||
decoded = http_ece.decrypt(
|
||||
encoded["body"],
|
||||
salt=raw_salt,
|
||||
dh=raw_dh,
|
||||
private_key=recv_key,
|
||||
auth_secret=raw_auth,
|
||||
version=content_encoding,
|
||||
)
|
||||
assert decoded.decode("utf8") == data
|
||||
|
||||
def test_bad_content_encoding(self):
|
||||
subscription_info = self._gen_subscription_info()
|
||||
data = "Mary had a little lamb, with some nice mint jelly"
|
||||
push = WebPusher(subscription_info)
|
||||
self.assertRaises(
|
||||
WebPushException, push.encode, data, content_encoding="aesgcm128"
|
||||
)
|
||||
|
||||
@patch("requests.post")
|
||||
def test_send(self, mock_post):
|
||||
subscription_info = self._gen_subscription_info()
|
||||
headers = {"Crypto-Key": "pre-existing", "Authentication": "bearer vapid"}
|
||||
data = "Mary had a little lamb"
|
||||
WebPusher(subscription_info).send(data, headers)
|
||||
assert subscription_info.get("endpoint") == mock_post.call_args[0][0]
|
||||
pheaders = mock_post.call_args[1].get("headers")
|
||||
assert pheaders.get("ttl") == "0"
|
||||
assert pheaders.get("AUTHENTICATION") == headers.get("Authentication")
|
||||
ckey = pheaders.get("crypto-key")
|
||||
assert "pre-existing" in ckey
|
||||
assert pheaders.get("content-encoding") == "aes128gcm"
|
||||
|
||||
@patch("requests.post")
|
||||
def test_send_vapid(self, mock_post):
|
||||
mock_post.return_value.status_code = 200
|
||||
subscription_info = self._gen_subscription_info()
|
||||
data = "Mary had a little lamb"
|
||||
webpush(
|
||||
subscription_info=subscription_info,
|
||||
data=data,
|
||||
vapid_private_key=self.vapid_key,
|
||||
vapid_claims={"sub": "mailto:ops@example.com"},
|
||||
content_encoding="aesgcm",
|
||||
headers={"Test-Header": "test-value"},
|
||||
)
|
||||
assert subscription_info.get("endpoint") == mock_post.call_args[0][0]
|
||||
pheaders = mock_post.call_args[1].get("headers")
|
||||
assert pheaders.get("ttl") == "0"
|
||||
|
||||
def repad(str):
|
||||
return str + "===="[: len(str) % 4]
|
||||
|
||||
auth = json.loads(
|
||||
base64.urlsafe_b64decode(
|
||||
repad(pheaders["authorization"].split(".")[1])
|
||||
).decode("utf8")
|
||||
)
|
||||
assert subscription_info.get("endpoint", "").startswith(auth["aud"])
|
||||
assert "vapid" in pheaders.get("authorization")
|
||||
ckey = pheaders.get("crypto-key")
|
||||
assert "dh=" in ckey
|
||||
assert pheaders.get("content-encoding") == "aesgcm"
|
||||
assert pheaders.get("test-header") == "test-value"
|
||||
|
||||
@patch.object(WebPusher, "send")
|
||||
@patch.object(py_vapid.Vapid, "sign")
|
||||
def test_webpush_vapid_instance(self, vapid_sign, pusher_send):
|
||||
pusher_send.return_value.status_code = 200
|
||||
subscription_info = self._gen_subscription_info()
|
||||
data = "Mary had a little lamb"
|
||||
vapid_key = py_vapid.Vapid.from_string(self.vapid_key)
|
||||
claims: Dict[str, Union[str, int]] = dict(
|
||||
sub="mailto:ops@example.com", aud="https://example.com"
|
||||
)
|
||||
webpush(
|
||||
subscription_info=subscription_info,
|
||||
data=data,
|
||||
vapid_private_key=vapid_key,
|
||||
vapid_claims=claims,
|
||||
)
|
||||
vapid_sign.assert_called_once_with(claims)
|
||||
pusher_send.assert_called_once()
|
||||
|
||||
@patch.object(WebPusher, "send")
|
||||
@patch.object(py_vapid.Vapid, "sign")
|
||||
def test_webpush_vapid_exp(self, vapid_sign, pusher_send):
|
||||
pusher_send.return_value.status_code = 200
|
||||
subscription_info = self._gen_subscription_info()
|
||||
data = "Mary had a little lamb"
|
||||
vapid_key = py_vapid.Vapid.from_string(self.vapid_key)
|
||||
claims = dict(
|
||||
sub="mailto:ops@example.com",
|
||||
aud="https://example.com",
|
||||
exp=int(time.time() - 48600),
|
||||
)
|
||||
webpush(
|
||||
subscription_info=subscription_info,
|
||||
data=data,
|
||||
vapid_private_key=vapid_key,
|
||||
vapid_claims=claims,
|
||||
)
|
||||
vapid_sign.assert_called_once_with(claims)
|
||||
pusher_send.assert_called_once()
|
||||
assert int(claims["exp"]) > int(time.time())
|
||||
|
||||
@patch("requests.post")
|
||||
def test_send_bad_vapid_no_key(self, mock_post):
|
||||
mock_post.return_value.status_code = 200
|
||||
|
||||
subscription_info = self._gen_subscription_info()
|
||||
data = "Mary had a little lamb"
|
||||
self.assertRaises(
|
||||
WebPushException,
|
||||
webpush,
|
||||
subscription_info=subscription_info,
|
||||
data=data,
|
||||
vapid_claims={
|
||||
"aud": "https://example.com",
|
||||
"sub": "mailto:ops@example.com",
|
||||
},
|
||||
)
|
||||
|
||||
@patch("requests.post")
|
||||
def test_send_bad_vapid_bad_return(self, mock_post):
|
||||
mock_post.return_value.status_code = 410
|
||||
|
||||
subscription_info = self._gen_subscription_info()
|
||||
data = "Mary had a little lamb"
|
||||
self.assertRaises(
|
||||
WebPushException,
|
||||
webpush,
|
||||
subscription_info=subscription_info,
|
||||
data=data,
|
||||
vapid_claims={
|
||||
"aud": "https://example.com",
|
||||
"sub": "mailto:ops@example.com",
|
||||
},
|
||||
vapid_private_key=self.vapid_key,
|
||||
)
|
||||
|
||||
@patch("requests.post")
|
||||
def test_send_empty(self, mock_post):
|
||||
subscription_info = self._gen_subscription_info()
|
||||
headers = {"Crypto-Key": "pre-existing", "Authentication": "bearer vapid"}
|
||||
WebPusher(subscription_info).send("", headers)
|
||||
assert subscription_info.get("endpoint") == mock_post.call_args[0][0]
|
||||
pheaders = mock_post.call_args[1].get("headers")
|
||||
assert pheaders.get("ttl") == "0"
|
||||
assert "encryption" not in pheaders
|
||||
assert pheaders.get("AUTHENTICATION") == headers.get("Authentication")
|
||||
ckey = pheaders.get("crypto-key")
|
||||
assert "pre-existing" in ckey
|
||||
|
||||
def test_encode_empty(self):
|
||||
subscription_info = self._gen_subscription_info()
|
||||
headers = {"Crypto-Key": "pre-existing", "Authentication": "bearer vapid"}
|
||||
pusher = WebPusher(subscription_info)
|
||||
self.assertRaises(NoData, pusher.encode, "", headers)
|
||||
|
||||
def test_encode_no_crypto(self):
|
||||
subscription_info = self._gen_subscription_info()
|
||||
del subscription_info["keys"]
|
||||
headers = {"Crypto-Key": "pre-existing", "Authentication": "bearer vapid"}
|
||||
data = "Something"
|
||||
pusher = WebPusher(subscription_info)
|
||||
self.assertRaises(WebPushException, pusher.encode, data, headers)
|
||||
|
||||
@patch("requests.post")
|
||||
def test_send_no_headers(self, mock_post):
|
||||
subscription_info = self._gen_subscription_info()
|
||||
data = "Mary had a little lamb"
|
||||
WebPusher(subscription_info).send(data)
|
||||
assert subscription_info.get("endpoint") == mock_post.call_args[0][0]
|
||||
pheaders = mock_post.call_args[1].get("headers")
|
||||
assert pheaders.get("ttl") == "0"
|
||||
assert pheaders.get("content-encoding") == "aes128gcm"
|
||||
|
||||
@patch("pywebpush.open")
|
||||
def test_as_curl(self, opener):
|
||||
subscription_info = self._gen_subscription_info()
|
||||
result = webpush(
|
||||
subscription_info,
|
||||
data="Mary had a little lamb",
|
||||
vapid_claims={
|
||||
"aud": "https://example.com",
|
||||
"sub": "mailto:ops@example.com",
|
||||
},
|
||||
vapid_private_key=self.vapid_key,
|
||||
curl=True,
|
||||
)
|
||||
result = cast(str, result)
|
||||
for s in [
|
||||
"curl -vX POST https://example.com",
|
||||
'-H "content-encoding: aes128gcm"',
|
||||
'-H "authorization: vapid ',
|
||||
'-H "ttl: 0"',
|
||||
'-H "content-length:',
|
||||
]:
|
||||
assert s in result, "missing: {}".format(s)
|
||||
|
||||
def test_ci_dict(self):
|
||||
ci = CaseInsensitiveDict({"Foo": "apple", "bar": "banana"})
|
||||
assert "apple" == ci["foo"]
|
||||
assert "apple" == ci.get("FOO")
|
||||
assert "apple" == ci.get("Foo")
|
||||
del ci["FOO"]
|
||||
assert ci.get("Foo") is None
|
||||
|
||||
@patch("requests.post")
|
||||
def test_gcm(self, mock_post):
|
||||
subscription_info = self._gen_subscription_info(
|
||||
None, endpoint="https://android.googleapis.com/gcm/send/regid123"
|
||||
)
|
||||
headers = {"Crypto-Key": "pre-existing", "Authentication": "bearer vapid"}
|
||||
data = "Mary had a little lamb"
|
||||
wp = WebPusher(subscription_info)
|
||||
wp.send(data, headers, gcm_key="gcm_key_value")
|
||||
pdata = json.loads(mock_post.call_args[1].get("data"))
|
||||
pheaders = mock_post.call_args[1].get("headers")
|
||||
assert pdata["registration_ids"][0] == "regid123"
|
||||
assert pheaders.get("authorization") == "key=gcm_key_value"
|
||||
assert pheaders.get("content-type") == "application/json"
|
||||
|
||||
@patch("requests.post")
|
||||
def test_timeout(self, mock_post):
|
||||
mock_post.return_value.status_code = 200
|
||||
subscription_info = self._gen_subscription_info()
|
||||
WebPusher(subscription_info).send(timeout=5.2)
|
||||
assert mock_post.call_args[1].get("timeout") == 5.2
|
||||
webpush(subscription_info, timeout=10.001)
|
||||
assert mock_post.call_args[1].get("timeout") == 10.001
|
||||
|
||||
@patch("requests.Session")
|
||||
def test_send_using_requests_session(self, mock_session):
|
||||
subscription_info = self._gen_subscription_info()
|
||||
headers = {"Crypto-Key": "pre-existing", "Authentication": "bearer vapid"}
|
||||
data = "Mary had a little lamb"
|
||||
WebPusher(subscription_info, requests_session=mock_session).send(data, headers)
|
||||
assert subscription_info.get("endpoint") == mock_session.post.call_args[0][0]
|
||||
pheaders = mock_session.post.call_args[1].get("headers")
|
||||
assert pheaders.get("ttl") == "0"
|
||||
assert pheaders.get("AUTHENTICATION") == headers.get("Authentication")
|
||||
ckey = pheaders.get("crypto-key")
|
||||
assert "pre-existing" in ckey
|
||||
assert pheaders.get("content-encoding") == "aes128gcm"
|
||||
|
||||
|
||||
class WebPusherAsyncTestCase(WebpushTestUtils, unittest.IsolatedAsyncioTestCase):
|
||||
@patch("aiohttp.ClientSession.post", new_callable=AsyncMock)
|
||||
async def test_send(self, mock_post):
|
||||
subscription_info = self._gen_subscription_info()
|
||||
headers = {"Crypto-Key": "pre-existing", "Authentication": "bearer vapid"}
|
||||
data = "Mary had a little lamb"
|
||||
await WebPusher(subscription_info).send_async(data, headers)
|
||||
assert subscription_info.get("endpoint") == mock_post.call_args[0][0]
|
||||
pheaders = mock_post.call_args[1].get("headers")
|
||||
assert pheaders.get("ttl") == "0"
|
||||
assert pheaders.get("AUTHENTICATION") == headers.get("Authentication")
|
||||
ckey = pheaders.get("crypto-key")
|
||||
assert "pre-existing" in ckey
|
||||
assert pheaders.get("content-encoding") == "aes128gcm"
|
||||
|
||||
@patch("aiohttp.ClientSession.post", new_callable=AsyncMock)
|
||||
async def test_send_empty(self, mock_post):
|
||||
subscription_info = self._gen_subscription_info()
|
||||
headers = {"Crypto-Key": "pre-existing", "Authentication": "bearer vapid"}
|
||||
await WebPusher(subscription_info).send_async("", headers)
|
||||
assert subscription_info.get("endpoint") == mock_post.call_args[0][0]
|
||||
pheaders = mock_post.call_args[1].get("headers")
|
||||
assert pheaders.get("ttl") == "0"
|
||||
assert "encryption" not in pheaders
|
||||
assert pheaders.get("AUTHENTICATION") == headers.get("Authentication")
|
||||
ckey = pheaders.get("crypto-key")
|
||||
assert "pre-existing" in ckey
|
||||
|
||||
@patch("aiohttp.ClientSession.post", new_callable=AsyncMock)
|
||||
async def test_send_no_headers(self, mock_post):
|
||||
subscription_info = self._gen_subscription_info()
|
||||
data = "Mary had a little lamb"
|
||||
await WebPusher(subscription_info).send_async(data)
|
||||
assert subscription_info.get("endpoint") == mock_post.call_args[0][0]
|
||||
pheaders = mock_post.call_args[1].get("headers")
|
||||
assert pheaders.get("ttl") == "0"
|
||||
assert pheaders.get("content-encoding") == "aes128gcm"
|
||||
|
||||
@patch("aiohttp.ClientSession.post", new_callable=AsyncMock)
|
||||
async def test_fcm(self, mock_post):
|
||||
subscription_info = self._gen_subscription_info(
|
||||
None, endpoint="https://android.googleapis.com/fcm/send/regid123"
|
||||
)
|
||||
headers = {"Crypto-Key": "pre-existing", "Authentication": "bearer vapid"}
|
||||
data = "Mary had a little lamb"
|
||||
wp = WebPusher(subscription_info)
|
||||
await wp.send_async(data, headers, gcm_key="gcm_key_value")
|
||||
pdata = json.loads(mock_post.call_args[1].get("data"))
|
||||
pheaders = mock_post.call_args[1].get("headers")
|
||||
assert pdata["registration_ids"][0] == "regid123"
|
||||
assert pheaders.get("authorization") == "key=gcm_key_value"
|
||||
assert pheaders.get("content-type") == "application/json"
|
||||
|
||||
@patch("aiohttp.ClientSession.post", new_callable=AsyncMock)
|
||||
async def test_timeout(self, mock_post):
|
||||
mock_post.return_value.status_code = 200
|
||||
subscription_info = self._gen_subscription_info()
|
||||
await WebPusher(subscription_info).send_async(timeout=5.2)
|
||||
assert mock_post.call_args[1].get("timeout") == 5.2
|
||||
|
||||
@patch("aiohttp.ClientSession", new_callable=AsyncMock)
|
||||
async def test_send_using_requests_session(self, mock_session):
|
||||
subscription_info = self._gen_subscription_info()
|
||||
headers = {"Crypto-Key": "pre-existing", "Authentication": "bearer vapid"}
|
||||
data = "Mary had a little lamb"
|
||||
await WebPusher(subscription_info, aiohttp_session=mock_session).send_async(
|
||||
data, headers
|
||||
)
|
||||
assert subscription_info.get("endpoint") == mock_session.post.call_args[0][0]
|
||||
pheaders = mock_session.post.call_args[1].get("headers")
|
||||
assert pheaders.get("ttl") == "0"
|
||||
assert pheaders.get("AUTHENTICATION") == headers.get("Authentication")
|
||||
ckey = pheaders.get("crypto-key")
|
||||
assert "pre-existing" in ckey
|
||||
assert pheaders.get("content-encoding") == "aes128gcm"
|
||||
|
||||
@patch("aiohttp.ClientSession.post", new_callable=AsyncMock)
|
||||
async def test_webpush_async_vapid(self, mock_post):
|
||||
mock_post.return_value.status = 200
|
||||
mock_post.return_value.text = AsyncMock(return_value="")
|
||||
subscription_info = self._gen_subscription_info()
|
||||
data = "Mary had a little lamb"
|
||||
await webpush_async(
|
||||
subscription_info=subscription_info,
|
||||
data=data,
|
||||
vapid_private_key=self.vapid_key,
|
||||
vapid_claims={"sub": "mailto:ops@example.com"},
|
||||
content_encoding="aesgcm",
|
||||
headers={"Test-Header": "test-value"},
|
||||
)
|
||||
assert subscription_info.get("endpoint") == mock_post.call_args[0][0]
|
||||
pheaders = mock_post.call_args[1].get("headers")
|
||||
assert pheaders.get("ttl") == "0"
|
||||
|
||||
def repad(str):
|
||||
return str + "===="[: len(str) % 4]
|
||||
|
||||
auth = json.loads(
|
||||
base64.urlsafe_b64decode(
|
||||
repad(pheaders["authorization"].split(".")[1])
|
||||
).decode("utf8")
|
||||
)
|
||||
assert subscription_info.get("endpoint", "").startswith(auth["aud"])
|
||||
assert "vapid" in pheaders.get("authorization")
|
||||
ckey = pheaders.get("crypto-key")
|
||||
assert "dh=" in ckey
|
||||
assert pheaders.get("content-encoding") == "aesgcm"
|
||||
assert pheaders.get("test-header") == "test-value"
|
||||
|
||||
@patch.object(WebPusher, "send_async")
|
||||
@patch.object(py_vapid.Vapid, "sign")
|
||||
async def test_webpush_async_vapid_instance(self, vapid_sign, pusher_send):
|
||||
mock_response = Mock()
|
||||
mock_response.status = 200
|
||||
pusher_send.return_value = mock_response
|
||||
subscription_info = self._gen_subscription_info()
|
||||
data = "Mary had a little lamb"
|
||||
vapid_key = py_vapid.Vapid.from_string(self.vapid_key)
|
||||
claims: Dict[str, Union[str, int]] = dict(
|
||||
sub="mailto:ops@example.com", aud="https://example.com"
|
||||
)
|
||||
await webpush_async(
|
||||
subscription_info=subscription_info,
|
||||
data=data,
|
||||
vapid_private_key=vapid_key,
|
||||
vapid_claims=claims,
|
||||
)
|
||||
vapid_sign.assert_called_once_with(claims)
|
||||
pusher_send.assert_called_once()
|
||||
|
||||
@patch.object(WebPusher, "send_async")
|
||||
@patch.object(py_vapid.Vapid, "sign")
|
||||
async def test_webpush_async_vapid_exp(self, vapid_sign, pusher_send):
|
||||
mock_response = Mock()
|
||||
mock_response.status = 200
|
||||
pusher_send.return_value = mock_response
|
||||
subscription_info = self._gen_subscription_info()
|
||||
data = "Mary had a little lamb"
|
||||
vapid_key = py_vapid.Vapid.from_string(self.vapid_key)
|
||||
claims = dict(
|
||||
sub="mailto:ops@example.com",
|
||||
aud="https://example.com",
|
||||
exp=int(time.time() - 48600),
|
||||
)
|
||||
await webpush_async(
|
||||
subscription_info=subscription_info,
|
||||
data=data,
|
||||
vapid_private_key=vapid_key,
|
||||
vapid_claims=claims,
|
||||
)
|
||||
vapid_sign.assert_called_once_with(claims)
|
||||
pusher_send.assert_called_once()
|
||||
assert int(claims["exp"]) > int(time.time())
|
||||
|
||||
async def test_webpush_async_bad_vapid_no_key(self):
|
||||
subscription_info = self._gen_subscription_info()
|
||||
data = "Mary had a little lamb"
|
||||
with self.assertRaises(WebPushException):
|
||||
await webpush_async(
|
||||
subscription_info=subscription_info,
|
||||
data=data,
|
||||
vapid_claims={
|
||||
"aud": "https://example.com",
|
||||
"sub": "mailto:ops@example.com",
|
||||
},
|
||||
)
|
||||
|
||||
@patch("aiohttp.ClientSession.post", new_callable=AsyncMock)
|
||||
async def test_webpush_async_bad_vapid_bad_return(self, mock_post):
|
||||
mock_post.return_value.status = 410
|
||||
mock_post.return_value.reason = "Gone"
|
||||
mock_post.return_value.text = AsyncMock(return_value="Subscription expired")
|
||||
|
||||
subscription_info = self._gen_subscription_info()
|
||||
data = "Mary had a little lamb"
|
||||
with self.assertRaises(WebPushException):
|
||||
await webpush_async(
|
||||
subscription_info=subscription_info,
|
||||
data=data,
|
||||
vapid_claims={
|
||||
"aud": "https://example.com",
|
||||
"sub": "mailto:ops@example.com",
|
||||
},
|
||||
vapid_private_key=self.vapid_key,
|
||||
)
|
||||
|
||||
@patch("aiohttp.ClientSession.post", new_callable=AsyncMock)
|
||||
async def test_webpush_async_timeout(self, mock_post):
|
||||
mock_response = Mock()
|
||||
mock_response.status = 200
|
||||
mock_response.text = AsyncMock(return_value="")
|
||||
mock_post.return_value = mock_response
|
||||
subscription_info = self._gen_subscription_info()
|
||||
await webpush_async(subscription_info, timeout=10.001)
|
||||
assert mock_post.call_args[1].get("timeout") == 10.001
|
||||
|
||||
async def test_webpush_async_as_curl(self):
|
||||
subscription_info = self._gen_subscription_info()
|
||||
result = await webpush_async(
|
||||
subscription_info,
|
||||
data="Mary had a little lamb",
|
||||
vapid_claims={
|
||||
"aud": "https://example.com",
|
||||
"sub": "mailto:ops@example.com",
|
||||
},
|
||||
vapid_private_key=self.vapid_key,
|
||||
curl=True,
|
||||
)
|
||||
result = cast(str, result)
|
||||
for s in [
|
||||
"curl -vX POST https://example.com",
|
||||
'-H "content-encoding: aes128gcm"',
|
||||
'-H "authorization: vapid ',
|
||||
'-H "ttl: 0"',
|
||||
'-H "content-length:',
|
||||
]:
|
||||
assert s in result, "missing: {}".format(s)
|
||||
|
||||
|
||||
class WebpushExceptionTestCase(unittest.TestCase):
|
||||
def test_exception(self):
|
||||
from requests import Response
|
||||
|
||||
exp = WebPushException("foo")
|
||||
assert "{}".format(exp) == "WebPushException: foo"
|
||||
# Really should try to load the response to verify, but this mock
|
||||
# covers what we need.
|
||||
response = Mock(spec=Response)
|
||||
response.text = (
|
||||
'{"code": 401, "errno": 109, "error": '
|
||||
'"Unauthorized", "more_info": "http://'
|
||||
"autopush.readthedocs.io/en/latest/htt"
|
||||
'p.html#error-codes", "message": "Requ'
|
||||
"est did not validate missing authoriz"
|
||||
'ation header"}'
|
||||
)
|
||||
response.json.return_value = json.loads(response.text)
|
||||
response.status_code = 401
|
||||
response.reason = "Unauthorized"
|
||||
exp = WebPushException("foo", response)
|
||||
assert "{}".format(exp) == "WebPushException: foo, Response {}".format(
|
||||
response.text
|
||||
)
|
||||
assert "{}".format(exp.response), "<Response [401]>"
|
||||
assert cast(requests.Response, exp.response).json().get("errno") == 109
|
||||
exp = WebPushException("foo", [1, 2, 3])
|
||||
assert "{}".format(exp) == "WebPushException: foo, Response [1, 2, 3]"
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Einmalig: das VAPID-Schluesselpaar fuer Web Push erzeugen.
|
||||
|
||||
Der private Schluessel bleibt auf der NAS (der Runner unterschreibt damit
|
||||
jede Nachricht), der oeffentliche geht in den Browser - er steckt im Abo und
|
||||
laesst sich nicht missbrauchen. Beide landen in push_vapid.json neben
|
||||
skoda.conf: ausserhalb des Web-Roots, aber fuer die Weboberflaeche lesbar,
|
||||
die den oeffentlichen Teil braucht.
|
||||
|
||||
Vorhandene Schluessel werden NICHT ueberschrieben - alle bestehenden Abos
|
||||
haengen daran und waeren sonst wertlos.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
ORDNER = "/volume1/homes/wagner/SolarManager"
|
||||
ZIEL = os.path.join(ORDNER, "push_vapid.json")
|
||||
sys.path.insert(0, ORDNER)
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
|
||||
|
||||
def b64(roh):
|
||||
return base64.urlsafe_b64encode(roh).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
if os.path.exists(ZIEL):
|
||||
with open(ZIEL) as f:
|
||||
vorhanden = json.load(f)
|
||||
print("existiert schon, unveraendert:", vorhanden.get("public_key", "")[:16] + "…")
|
||||
sys.exit(0)
|
||||
|
||||
schluessel = ec.generate_private_key(ec.SECP256R1())
|
||||
privat = schluessel.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption()).decode("ascii")
|
||||
oeffentlich = schluessel.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.X962,
|
||||
format=serialization.PublicFormat.UncompressedPoint)
|
||||
|
||||
with open(ZIEL, "w") as f:
|
||||
json.dump({
|
||||
"public_key": b64(oeffentlich),
|
||||
"private_key_pem": privat,
|
||||
# Wen die Push-Dienste bei Problemen erreichen. Muss eine mailto- oder
|
||||
# https-Adresse sein, sonst lehnen manche Dienste ab.
|
||||
"subject": "mailto:m0w1337@gmail.com",
|
||||
}, f, indent=2)
|
||||
os.chmod(ZIEL, 0o644)
|
||||
print("neu erzeugt:", b64(oeffentlich))
|
||||
Reference in New Issue
Block a user