|
| 1 | +# This Source Code Form is subject to the terms of the Mozilla Public |
| 2 | +# License, v. 2.0. If a copy of the MPL was not distributed with this |
| 3 | +# file, You can obtain one at http://mozilla.org/MPL/2.0/. |
| 4 | + |
| 5 | +import base64 |
| 6 | +import os |
| 7 | + |
| 8 | +import http_ece |
| 9 | +import pyelliptic |
| 10 | +import requests |
| 11 | + |
| 12 | + |
| 13 | +class WebPushException(Exception): |
| 14 | + pass |
| 15 | + |
| 16 | + |
| 17 | +class WebPusher: |
| 18 | + """WebPusher encrypts a data block using HTTP Encrypted Content Encoding |
| 19 | + for WebPush. |
| 20 | +
|
| 21 | + See https://tools.ietf.org/html/draft-ietf-webpush-protocol-04 |
| 22 | + for the current specification, and |
| 23 | + https://developer.mozilla.org/en-US/docs/Web/API/Push_API for an |
| 24 | + overview of Web Push. |
| 25 | +
|
| 26 | + Example of use: |
| 27 | +
|
| 28 | + The javascript promise handler for PushManager.subscribe() |
| 29 | + receives a subscription_info object. subscription_info.getJSON() |
| 30 | + will return a JSON representation. |
| 31 | + (e.g. |
| 32 | + .. code-block:: javascript |
| 33 | + subscription_info.getJSON() == |
| 34 | + {"endpoint": "https://push...", |
| 35 | + "keys":{"auth": "...", "p256dh": "..."} |
| 36 | + } |
| 37 | + ) |
| 38 | +
|
| 39 | + This subscription_info block can be stored. |
| 40 | +
|
| 41 | + To send a subscription update: |
| 42 | +
|
| 43 | + .. code-block:: python |
| 44 | + # Optional |
| 45 | + # headers = py_vapid.sign({"aud": "http://your.site.com", |
| 46 | + "sub": "mailto:your_admin@your.site.com"}) |
| 47 | + data = "Mary had a little lamb, with a nice mint jelly" |
| 48 | + WebPusher(subscription_info).send(data, headers) |
| 49 | +
|
| 50 | + """ |
| 51 | + subscription_info = {} |
| 52 | + |
| 53 | + def __init__(self, subscription_info): |
| 54 | + """Initialize using the info provided by the client PushSubscription |
| 55 | + object (See |
| 56 | + https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe) |
| 57 | +
|
| 58 | + :param subscription_info: a dict containing the subscription_info from |
| 59 | + the client. |
| 60 | +
|
| 61 | + """ |
| 62 | + if 'endpoint' not in subscription_info: |
| 63 | + raise WebPushException("subscription_info missing endpoint URL") |
| 64 | + if 'keys' not in subscription_info: |
| 65 | + raise WebPushException("subscription_info missing keys dictionary") |
| 66 | + self.subscription_info = subscription_info |
| 67 | + keys = self.subscription_info['keys'] |
| 68 | + for k in ['p256dh', 'auth']: |
| 69 | + if keys.get(k) is None: |
| 70 | + raise WebPushException("Missing keys value: %s", k) |
| 71 | + receiver_raw = base64.urlsafe_b64decode(self._repad(keys['p256dh'])) |
| 72 | + if len(receiver_raw) != 65 and receiver_raw[0] != "\x04": |
| 73 | + raise WebPushException("Invalid p256dh key specified") |
| 74 | + self.receiver_key = receiver_raw |
| 75 | + self.auth_key = base64.urlsafe_b64decode(self._repad(keys['auth'])) |
| 76 | + |
| 77 | + def _repad(self, str): |
| 78 | + """Add base64 padding to the end of a string, if required""" |
| 79 | + return str + "===="[:len(str) % 4] |
| 80 | + |
| 81 | + def encode(self, data): |
| 82 | + """Encrypt the data. |
| 83 | +
|
| 84 | + :param data: A serialized block of data (String, JSON, bit array, |
| 85 | + etc.) Make sure that whatever you send, your client knows how |
| 86 | + to understand it. |
| 87 | +
|
| 88 | + """ |
| 89 | + # Salt is a random 16 byte array. |
| 90 | + salt = os.urandom(16) |
| 91 | + # The server key is an ephemeral ECDH key used only for this |
| 92 | + # transaction |
| 93 | + server_key = pyelliptic.ECC(curve="prime256v1") |
| 94 | + # the ID is the base64 of the raw key, minus the leading "\x04" |
| 95 | + # ID tag. |
| 96 | + server_key_id = base64.urlsafe_b64encode(server_key.get_pubkey()[1:]) |
| 97 | + |
| 98 | + # http_ece requires that these both be set BEFORE encrypt or |
| 99 | + # decrypt is called. |
| 100 | + http_ece.keys[server_key_id] = server_key |
| 101 | + http_ece.labels[server_key_id] = "P-256" |
| 102 | + |
| 103 | + encrypted = http_ece.encrypt( |
| 104 | + data, |
| 105 | + salt=salt, |
| 106 | + keyid=server_key_id, |
| 107 | + dh=self.receiver_key, |
| 108 | + authSecret=self.auth_key) |
| 109 | + |
| 110 | + return { |
| 111 | + 'crypto_key': base64.urlsafe_b64encode( |
| 112 | + server_key.get_pubkey()).strip('='), |
| 113 | + 'salt': base64.urlsafe_b64encode(salt).strip("="), |
| 114 | + 'body': encrypted, |
| 115 | + } |
| 116 | + |
| 117 | + def send(self, data, headers={}, ttl=0): |
| 118 | + """Encode and send the data to the Push Service. |
| 119 | +
|
| 120 | + :param data: A serialized block of data (see encode() ). |
| 121 | + :param headers: A dictionary containing any additional HTTP headers. |
| 122 | + :param ttl: The Time To Live in seconds for this message if the |
| 123 | + recipient is not online. (Defaults to "0", which discards the |
| 124 | + message immediately if the recipient is unavailable.) |
| 125 | +
|
| 126 | + """ |
| 127 | + # Encode the data. |
| 128 | + encoded = self.encode(data) |
| 129 | + # Append the p256dh to the end of any existing crypto-key |
| 130 | + crypto_key = headers.get("crypto-key", "") |
| 131 | + if crypto_key: |
| 132 | + crypto_key += ',' |
| 133 | + crypto_key += "keyid=p256dh;dh=" + encoded["crypto_key"] |
| 134 | + headers.update({ |
| 135 | + 'crypto-key': crypto_key, |
| 136 | + 'content-encoding': 'aesgcm', |
| 137 | + 'encryption': "keyid=p256dh;salt=" + encoded['salt'], |
| 138 | + }) |
| 139 | + if 'ttl' not in headers or ttl: |
| 140 | + headers['ttl'] = ttl |
| 141 | + # Additionally useful headers: |
| 142 | + # Authorization / Crypto-Key (VAPID headers) |
| 143 | + return requests.post(self.subscription_info['endpoint'], |
| 144 | + data=encoded.get('body'), |
| 145 | + headers=headers) |
0 commit comments