121 lines
4.6 KiB
Python
121 lines
4.6 KiB
Python
"""Web Push protocol: RFC 8291 encryption (against the RFC's worked example and
|
|||
|
|
by decrypting as a browser would), VAPID signatures, the push-service allow-list."""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import json
|
||
|
|
|
||
|
|
from cryptography.hazmat.primitives import hashes, serialization
|
||
|
|
from cryptography.hazmat.primitives.asymmetric import ec
|
||
|
|
from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature
|
||
|
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||
|
|
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||
|
|
|
||
|
|
from app.push import webpush
|
||
|
|
from app.push.webpush import b64u_decode, b64u_encode
|
||
|
|
|
||
|
|
# RFC 8291 §5 / Appendix A.
|
||
|
|
RFC_AUTH = "BTBZMqHH6r4Tts7J_aSIgg"
|
||
|
|
RFC_UA_PUBLIC = (
|
||
|
|
"BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4"
|
||
|
|
)
|
||
|
|
RFC_AS_PRIVATE = "yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw"
|
||
|
|
RFC_SALT = "DGv6ra1nlYgDCS1FRnbzlw"
|
||
|
|
RFC_BODY = (
|
||
|
|
"DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27ml"
|
||
|
|
"mlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPT"
|
||
|
|
"pK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _hkdf(salt: bytes, ikm: bytes, info: bytes, n: int) -> bytes:
|
||
|
|
return HKDF(algorithm=hashes.SHA256(), length=n, salt=salt, info=info).derive(ikm)
|
||
|
|
|
||
|
|
|
||
|
|
def browser_decrypt(body: bytes, ua: ec.EllipticCurvePrivateKey, auth: bytes) -> bytes:
|
||
|
|
"""What the browser does with a push body (RFC 8291 receiver side)."""
|
||
|
|
salt, idlen = body[:16], body[20]
|
||
|
|
as_public = body[21 : 21 + idlen]
|
||
|
|
ua_public = ua.public_key().public_bytes(
|
||
|
|
serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint
|
||
|
|
)
|
||
|
|
as_key = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), as_public)
|
||
|
|
ikm = _hkdf(
|
||
|
|
auth, ua.exchange(ec.ECDH(), as_key), b"WebPush: info\x00" + ua_public + as_public, 32
|
||
|
|
)
|
||
|
|
cek = _hkdf(salt, ikm, b"Content-Encoding: aes128gcm\x00", 16)
|
||
|
|
nonce = _hkdf(salt, ikm, b"Content-Encoding: nonce\x00", 12)
|
||
|
|
plain = AESGCM(cek).decrypt(nonce, body[21 + idlen :], None)
|
||
|
|
assert plain.endswith(b"\x02")
|
||
|
|
return plain[:-1]
|
||
|
|
|
||
|
|
|
||
|
|
def test_rfc8291_worked_example() -> None:
|
||
|
|
sender = ec.derive_private_key(
|
||
|
|
int.from_bytes(b64u_decode(RFC_AS_PRIVATE), "big"), ec.SECP256R1()
|
||
|
|
)
|
||
|
|
body = webpush.encrypt(
|
||
|
|
b"When I grow up, I want to be a watermelon",
|
||
|
|
RFC_UA_PUBLIC,
|
||
|
|
RFC_AUTH,
|
||
|
|
salt=b64u_decode(RFC_SALT),
|
||
|
|
sender=sender,
|
||
|
|
)
|
||
|
|
assert b64u_encode(body) == RFC_BODY
|
||
|
|
|
||
|
|
|
||
|
|
def test_browser_can_decrypt() -> None:
|
||
|
|
ua = ec.generate_private_key(ec.SECP256R1())
|
||
|
|
auth = b"0123456789abcdef"
|
||
|
|
p256dh = b64u_encode(
|
||
|
|
ua.public_key().public_bytes(
|
||
|
|
serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint
|
||
|
|
)
|
||
|
|
)
|
||
|
|
payload = json.dumps({"title": "Zażółć gęślą jaźń"}).encode()
|
||
|
|
body = webpush.encrypt(payload, p256dh, b64u_encode(auth))
|
||
|
|
assert browser_decrypt(body, ua, auth) == payload
|
||
|
|
|
||
|
|
|
||
|
|
def test_vapid_authorization_is_a_valid_es256_jwt() -> None:
|
||
|
|
key = webpush.Vapid.generate()
|
||
|
|
again = webpush.Vapid.from_pem(key.pem())
|
||
|
|
assert again.public_b64 == key.public_b64
|
||
|
|
header = key.authorization("https://fcm.googleapis.com/fcm/send/abc", "https://ultimo.email")
|
||
|
|
token = header.split("t=")[1].split(",")[0]
|
||
|
|
assert header.endswith(f"k={key.public_b64}")
|
||
|
|
head, claims, sig = token.split(".")
|
||
|
|
assert json.loads(b64u_decode(claims)) | {"exp": 0} == {
|
||
|
|
"aud": "https://fcm.googleapis.com",
|
||
|
|
"sub": "https://ultimo.email",
|
||
|
|
"exp": 0,
|
||
|
|
}
|
||
|
|
raw = b64u_decode(sig)
|
||
|
|
der = encode_dss_signature(int.from_bytes(raw[:32], "big"), int.from_bytes(raw[32:], "big"))
|
||
|
|
public = ec.EllipticCurvePublicKey.from_encoded_point(
|
||
|
|
ec.SECP256R1(), b64u_decode(key.public_b64)
|
||
|
|
)
|
||
|
|
public.verify(der, f"{head}.{claims}".encode(), ec.ECDSA(hashes.SHA256())) # raises if bad
|
||
|
|
|
||
|
|
|
||
|
|
def test_only_push_services_are_allowed() -> None:
|
||
|
|
ok = [
|
||
|
|
"https://fcm.googleapis.com/fcm/send/x",
|
||
|
|
"https://jmt17.google.com/fcm/send/x",
|
||
|
|
"https://updates.push.services.mozilla.com/wpush/v2/x",
|
||
|
|
"https://web.push.apple.com/x",
|
||
|
|
"https://wns2-db5p.notify.windows.com/w/?token=x",
|
||
|
|
]
|
||
|
|
bad = [
|
||
|
|
"http://fcm.googleapis.com/fcm/send/x",
|
||
|
|
"https://fcm.googleapis.com:8443/x",
|
||
|
|
"https://evil.com/fcm.googleapis.com",
|
||
|
|
"https://fcm.googleapis.com.evil.com/x",
|
||
|
|
"https://google.com.evil.com/x",
|
||
|
|
"https://notapple-push.apple.com.evil.com/x",
|
||
|
|
"https://127.0.0.1/x",
|
||
|
|
"https://postgres/x",
|
||
|
|
]
|
||
|
|
assert all(webpush.allowed_endpoint(u) for u in ok)
|
||
|
|
assert not any(webpush.allowed_endpoint(u) for u in bad)
|