py-vapid 1.9.3__py2.py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- py_vapid/__init__.py +387 -0
- py_vapid/__main__.py +137 -0
- py_vapid/jwt.py +87 -0
- py_vapid/main.py +115 -0
- py_vapid/tests/.test_vapid.py.swp +0 -0
- py_vapid/tests/test_vapid.py +282 -0
- py_vapid/utils.py +39 -0
- py_vapid-1.9.3.dist-info/METADATA +127 -0
- py_vapid-1.9.3.dist-info/RECORD +12 -0
- py_vapid-1.9.3.dist-info/WHEEL +5 -0
- py_vapid-1.9.3.dist-info/entry_points.txt +2 -0
- py_vapid-1.9.3.dist-info/licenses/LICENSE +373 -0
py_vapid/__init__.py
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
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 os
|
|
6
|
+
import logging
|
|
7
|
+
import binascii
|
|
8
|
+
import time
|
|
9
|
+
import re
|
|
10
|
+
import copy
|
|
11
|
+
|
|
12
|
+
from cryptography.hazmat.backends import default_backend
|
|
13
|
+
from cryptography.hazmat.primitives.asymmetric import ec, utils as ecutils
|
|
14
|
+
from cryptography.hazmat.primitives import serialization
|
|
15
|
+
|
|
16
|
+
from cryptography.hazmat.primitives import hashes
|
|
17
|
+
from cryptography.exceptions import InvalidSignature
|
|
18
|
+
|
|
19
|
+
from py_vapid.utils import b64urldecode, b64urlencode
|
|
20
|
+
from py_vapid.jwt import sign
|
|
21
|
+
|
|
22
|
+
# Show compliance version. For earlier versions see previously tagged releases.
|
|
23
|
+
VERSION = "VAPID-RFC/ECE-RFC"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class VapidException(Exception):
|
|
27
|
+
"""An exception wrapper for Vapid."""
|
|
28
|
+
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Vapid01(object):
|
|
33
|
+
"""Minimal VAPID Draft 01 signature generation library.
|
|
34
|
+
|
|
35
|
+
https://tools.ietf.org/html/draft-ietf-webpush-vapid-01
|
|
36
|
+
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
_private_key = None
|
|
40
|
+
_public_key = None
|
|
41
|
+
_schema = "WebPush"
|
|
42
|
+
|
|
43
|
+
def __init__(self, private_key=None, conf=None):
|
|
44
|
+
"""Initialize VAPID with an optional private key.
|
|
45
|
+
|
|
46
|
+
:param private_key: A private key object
|
|
47
|
+
:type private_key: ec.EllipticCurvePrivateKey
|
|
48
|
+
|
|
49
|
+
"""
|
|
50
|
+
if conf is None:
|
|
51
|
+
conf = {}
|
|
52
|
+
self.conf = conf
|
|
53
|
+
self.private_key = private_key
|
|
54
|
+
if private_key:
|
|
55
|
+
self._public_key = self.private_key.public_key()
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def from_raw(cls, private_raw):
|
|
59
|
+
"""Initialize VAPID using a private key point in "raw" or
|
|
60
|
+
"uncompressed" form. Raw keys consist of a single, 32 octet
|
|
61
|
+
encoded integer.
|
|
62
|
+
|
|
63
|
+
:param private_raw: A private key point in uncompressed form.
|
|
64
|
+
:type private_raw: bytes
|
|
65
|
+
|
|
66
|
+
"""
|
|
67
|
+
key = ec.derive_private_key(
|
|
68
|
+
int(binascii.hexlify(b64urldecode(private_raw)), 16),
|
|
69
|
+
curve=ec.SECP256R1(),
|
|
70
|
+
backend=default_backend(),
|
|
71
|
+
)
|
|
72
|
+
return cls(key)
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
def from_raw_public(cls, public_raw):
|
|
76
|
+
key = ec.EllipticCurvePublicKey.from_encoded_point(
|
|
77
|
+
curve=ec.SECP256R1(), data=b64urldecode(public_raw)
|
|
78
|
+
)
|
|
79
|
+
ss = cls()
|
|
80
|
+
ss._public_key = key
|
|
81
|
+
return ss
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def from_pem(cls, private_key):
|
|
85
|
+
"""Initialize VAPID using a private key in PEM format.
|
|
86
|
+
|
|
87
|
+
:param private_key: A private key in PEM format.
|
|
88
|
+
:type private_key: bytes
|
|
89
|
+
|
|
90
|
+
"""
|
|
91
|
+
# not sure why, but load_pem_private_key fails to deserialize
|
|
92
|
+
return cls.from_der(b"".join(private_key.splitlines()[1:-1]))
|
|
93
|
+
|
|
94
|
+
@classmethod
|
|
95
|
+
def from_der(cls, private_key):
|
|
96
|
+
"""Initialize VAPID using a private key in DER format.
|
|
97
|
+
|
|
98
|
+
:param private_key: A private key in DER format and Base64-encoded.
|
|
99
|
+
:type private_key: bytes
|
|
100
|
+
|
|
101
|
+
"""
|
|
102
|
+
key = serialization.load_der_private_key(
|
|
103
|
+
b64urldecode(private_key), password=None, backend=default_backend()
|
|
104
|
+
)
|
|
105
|
+
return cls(key)
|
|
106
|
+
|
|
107
|
+
@classmethod
|
|
108
|
+
def from_file(cls, private_key_file=None):
|
|
109
|
+
"""Initialize VAPID using a file containing a private key in PEM or
|
|
110
|
+
DER format.
|
|
111
|
+
|
|
112
|
+
:param private_key_file: Name of the file containing the private key
|
|
113
|
+
:type private_key_file: str
|
|
114
|
+
|
|
115
|
+
"""
|
|
116
|
+
if not os.path.isfile(private_key_file):
|
|
117
|
+
logging.info("Private key not found, generating key...")
|
|
118
|
+
vapid = cls()
|
|
119
|
+
vapid.generate_keys()
|
|
120
|
+
vapid.save_key(private_key_file)
|
|
121
|
+
return vapid
|
|
122
|
+
with open(private_key_file, "r") as file:
|
|
123
|
+
private_key = file.read()
|
|
124
|
+
try:
|
|
125
|
+
if "-----BEGIN" in private_key:
|
|
126
|
+
vapid = cls.from_pem(private_key.encode("utf8"))
|
|
127
|
+
else:
|
|
128
|
+
vapid = cls.from_der(private_key.encode("utf8"))
|
|
129
|
+
return vapid
|
|
130
|
+
except Exception as exc:
|
|
131
|
+
logging.error("Could not open private key file: %s", repr(exc))
|
|
132
|
+
raise VapidException(exc)
|
|
133
|
+
|
|
134
|
+
@classmethod
|
|
135
|
+
def from_string(cls, private_key):
|
|
136
|
+
"""Initialize VAPID using a string containing the private key. This
|
|
137
|
+
will try to determine if the key is in RAW or DER format.
|
|
138
|
+
|
|
139
|
+
:param private_key: String containing the key info
|
|
140
|
+
:type private_key: str
|
|
141
|
+
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
pkey = private_key.encode().replace(b"\n", b"")
|
|
145
|
+
key = b64urldecode(pkey)
|
|
146
|
+
if len(key) == 32:
|
|
147
|
+
return cls.from_raw(pkey)
|
|
148
|
+
return cls.from_der(pkey)
|
|
149
|
+
|
|
150
|
+
@classmethod
|
|
151
|
+
def verify(cls, key, auth):
|
|
152
|
+
"""Verify a VAPID authorization token.
|
|
153
|
+
|
|
154
|
+
:param key: base64 serialized public key
|
|
155
|
+
:type key: str
|
|
156
|
+
:param auth: authorization token
|
|
157
|
+
type key: str
|
|
158
|
+
|
|
159
|
+
"""
|
|
160
|
+
tokens = auth.rsplit(" ", 1)[1].rsplit(".", 1)
|
|
161
|
+
kp = cls().from_raw_public(key.encode())
|
|
162
|
+
return kp.verify_token(
|
|
163
|
+
validation_token=tokens[0].encode(), verification_token=tokens[1]
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def private_key(self):
|
|
168
|
+
"""The VAPID private ECDSA key"""
|
|
169
|
+
if not self._private_key:
|
|
170
|
+
raise VapidException("No private key. Call generate_keys()")
|
|
171
|
+
return self._private_key
|
|
172
|
+
|
|
173
|
+
@private_key.setter
|
|
174
|
+
def private_key(self, value):
|
|
175
|
+
"""Set the VAPID private ECDSA key
|
|
176
|
+
|
|
177
|
+
:param value: the byte array containing the private ECDSA key data
|
|
178
|
+
:type value: ec.EllipticCurvePrivateKey
|
|
179
|
+
|
|
180
|
+
"""
|
|
181
|
+
self._private_key = value
|
|
182
|
+
if value:
|
|
183
|
+
self._public_key = self.private_key.public_key()
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def public_key(self):
|
|
187
|
+
"""The VAPID public ECDSA key
|
|
188
|
+
|
|
189
|
+
The public key is currently read only. Set it via the `.private_key`
|
|
190
|
+
method. This will autogenerate a public and private key if no value
|
|
191
|
+
has been set.
|
|
192
|
+
|
|
193
|
+
:returns ec.EllipticCurvePublicKey
|
|
194
|
+
|
|
195
|
+
"""
|
|
196
|
+
return self._public_key
|
|
197
|
+
|
|
198
|
+
def generate_keys(self):
|
|
199
|
+
"""Generate a valid ECDSA Key Pair."""
|
|
200
|
+
self.private_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
|
|
201
|
+
|
|
202
|
+
def private_pem(self):
|
|
203
|
+
return self.private_key.private_bytes(
|
|
204
|
+
encoding=serialization.Encoding.PEM,
|
|
205
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
206
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
def public_pem(self):
|
|
210
|
+
return self.public_key.public_bytes(
|
|
211
|
+
encoding=serialization.Encoding.PEM,
|
|
212
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
def save_key(self, key_file):
|
|
216
|
+
"""Save the private key to a PEM file.
|
|
217
|
+
|
|
218
|
+
:param key_file: The file path to save the private key data
|
|
219
|
+
:type key_file: str
|
|
220
|
+
|
|
221
|
+
"""
|
|
222
|
+
with open(key_file, "wb") as file:
|
|
223
|
+
file.write(self.private_pem())
|
|
224
|
+
file.close()
|
|
225
|
+
|
|
226
|
+
def save_public_key(self, key_file):
|
|
227
|
+
"""Save the public key to a PEM file.
|
|
228
|
+
:param key_file: The name of the file to save the public key
|
|
229
|
+
:type key_file: str
|
|
230
|
+
|
|
231
|
+
"""
|
|
232
|
+
with open(key_file, "wb") as file:
|
|
233
|
+
file.write(self.public_pem())
|
|
234
|
+
file.close()
|
|
235
|
+
|
|
236
|
+
def verify_token(self, validation_token, verification_token):
|
|
237
|
+
"""Internally used to verify the verification token is correct.
|
|
238
|
+
|
|
239
|
+
:param validation_token: Provided validation token string
|
|
240
|
+
:type validation_token: str
|
|
241
|
+
:param verification_token: Generated verification token
|
|
242
|
+
:type verification_token: str
|
|
243
|
+
:returns: Boolean indicating if verifictation token is valid.
|
|
244
|
+
:rtype: boolean
|
|
245
|
+
|
|
246
|
+
"""
|
|
247
|
+
hsig = b64urldecode(verification_token.encode("utf8"))
|
|
248
|
+
r = int(binascii.hexlify(hsig[:32]), 16)
|
|
249
|
+
s = int(binascii.hexlify(hsig[32:]), 16)
|
|
250
|
+
try:
|
|
251
|
+
self.public_key.verify(
|
|
252
|
+
ecutils.encode_dss_signature(r, s),
|
|
253
|
+
validation_token,
|
|
254
|
+
signature_algorithm=ec.ECDSA(hashes.SHA256()),
|
|
255
|
+
)
|
|
256
|
+
return True
|
|
257
|
+
except InvalidSignature:
|
|
258
|
+
return False
|
|
259
|
+
|
|
260
|
+
def _base_sign(self, claims):
|
|
261
|
+
cclaims = copy.deepcopy(claims)
|
|
262
|
+
if not cclaims.get("exp"):
|
|
263
|
+
cclaims["exp"] = int(time.time()) + 86400
|
|
264
|
+
if not self.conf.get("no-strict", False):
|
|
265
|
+
valid = _check_sub(cclaims.get("sub", ""))
|
|
266
|
+
else:
|
|
267
|
+
valid = cclaims.get("sub") is not None
|
|
268
|
+
if not valid:
|
|
269
|
+
raise VapidException(
|
|
270
|
+
"Missing 'sub' from claims. "
|
|
271
|
+
"'sub' is your admin email as a mailto: link."
|
|
272
|
+
)
|
|
273
|
+
if not re.match(
|
|
274
|
+
r"^https?://[^/:]+(:\d+)?$", cclaims.get("aud", ""), re.IGNORECASE
|
|
275
|
+
):
|
|
276
|
+
raise VapidException(
|
|
277
|
+
"Missing 'aud' from claims. "
|
|
278
|
+
"'aud' is the scheme, host and optional port for this "
|
|
279
|
+
"transaction e.g. https://example.com:8080"
|
|
280
|
+
)
|
|
281
|
+
return cclaims
|
|
282
|
+
|
|
283
|
+
def sign(self, claims, crypto_key=None):
|
|
284
|
+
"""Sign a set of claims.
|
|
285
|
+
:param claims: JSON object containing the JWT claims to use.
|
|
286
|
+
:type claims: dict
|
|
287
|
+
:param crypto_key: Optional existing crypto_key header content. The
|
|
288
|
+
vapid public key will be appended to this data.
|
|
289
|
+
:type crypto_key: str
|
|
290
|
+
:returns: a hash containing the header fields to use in
|
|
291
|
+
the subscription update.
|
|
292
|
+
:rtype: dict
|
|
293
|
+
|
|
294
|
+
"""
|
|
295
|
+
sig = sign(self._base_sign(claims), self.private_key)
|
|
296
|
+
pkey = "p256ecdsa="
|
|
297
|
+
pkey += b64urlencode(
|
|
298
|
+
self.public_key.public_bytes(
|
|
299
|
+
serialization.Encoding.X962,
|
|
300
|
+
serialization.PublicFormat.UncompressedPoint,
|
|
301
|
+
)
|
|
302
|
+
)
|
|
303
|
+
if crypto_key:
|
|
304
|
+
crypto_key = crypto_key + ";" + pkey
|
|
305
|
+
else:
|
|
306
|
+
crypto_key = pkey
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
"Authorization": "{} {}".format(self._schema, sig.strip("=")),
|
|
310
|
+
"Crypto-Key": crypto_key,
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
class Vapid02(Vapid01):
|
|
315
|
+
"""Minimal Vapid RFC8292 signature generation library
|
|
316
|
+
|
|
317
|
+
https://tools.ietf.org/html/rfc8292
|
|
318
|
+
|
|
319
|
+
"""
|
|
320
|
+
|
|
321
|
+
_schema = "vapid"
|
|
322
|
+
|
|
323
|
+
def sign(self, claims, crypto_key=None):
|
|
324
|
+
"""Generate an authorization token
|
|
325
|
+
|
|
326
|
+
:param claims: JSON object containing the JWT claims to use.
|
|
327
|
+
:type claims: dict
|
|
328
|
+
:param crypto_key: Optional existing crypto_key header content. The
|
|
329
|
+
vapid public key will be appended to this data.
|
|
330
|
+
:type crypto_key: str
|
|
331
|
+
:returns: a hash containing the header fields to use in
|
|
332
|
+
the subscription update.
|
|
333
|
+
:rtype: dict
|
|
334
|
+
"""
|
|
335
|
+
sig = sign(self._base_sign(claims), self.private_key)
|
|
336
|
+
pkey = self.public_key.public_bytes(
|
|
337
|
+
serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint
|
|
338
|
+
)
|
|
339
|
+
return {
|
|
340
|
+
"Authorization": "{schema} t={t},k={k}".format(
|
|
341
|
+
schema=self._schema, t=sig, k=b64urlencode(pkey)
|
|
342
|
+
)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
@classmethod
|
|
346
|
+
def verify(cls, auth):
|
|
347
|
+
"""Ensure that the token is correctly formatted and valid
|
|
348
|
+
|
|
349
|
+
:param auth: An Authorization header
|
|
350
|
+
:type auth: str
|
|
351
|
+
:rtype: bool
|
|
352
|
+
|
|
353
|
+
"""
|
|
354
|
+
pref_tok = auth.rsplit(" ", 1)
|
|
355
|
+
assert pref_tok[0].lower() == cls._schema, "Incorrect schema specified"
|
|
356
|
+
parts = {}
|
|
357
|
+
for tok in pref_tok[1].split(","):
|
|
358
|
+
kv = tok.split("=", 1)
|
|
359
|
+
parts[kv[0]] = kv[1]
|
|
360
|
+
assert "k" in parts.keys(), "Auth missing public key 'k' value"
|
|
361
|
+
assert "t" in parts.keys(), "Auth missing token set 't' value"
|
|
362
|
+
kp = cls().from_raw_public(parts["k"].encode())
|
|
363
|
+
tokens = parts["t"].rsplit(".", 1)
|
|
364
|
+
return kp.verify_token(
|
|
365
|
+
validation_token=tokens[0].encode(), verification_token=tokens[1]
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _check_sub(sub):
|
|
370
|
+
"""Check to see if the `sub` is a properly formatted `mailto:`
|
|
371
|
+
|
|
372
|
+
a `mailto:` should be a SMTP mail address. Mind you, since I run
|
|
373
|
+
YouFailAtEmail.com, you have every right to yell about how terrible
|
|
374
|
+
this check is. I really should be doing a proper component parse
|
|
375
|
+
and valiate each component individually per RFC5341, instead I do
|
|
376
|
+
the unholy regex you see below.
|
|
377
|
+
|
|
378
|
+
:param sub: Candidate JWT `sub`
|
|
379
|
+
:type sub: str
|
|
380
|
+
:rtype: bool
|
|
381
|
+
|
|
382
|
+
"""
|
|
383
|
+
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
|
|
384
|
+
return re.match(pattern, sub, re.IGNORECASE) is not None
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
Vapid = Vapid02
|
py_vapid/__main__.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
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 argparse
|
|
6
|
+
import os
|
|
7
|
+
import json
|
|
8
|
+
|
|
9
|
+
from typing import cast
|
|
10
|
+
from cryptography.hazmat.primitives import serialization
|
|
11
|
+
from py_vapid import Vapid01, Vapid02, b64urlencode
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def prompt(prompt: str) -> str:
|
|
15
|
+
# Not sure why, but python3 throws and exception if you try to
|
|
16
|
+
# monkeypatch for this. It's ugly, but this seems to play nicer.
|
|
17
|
+
try:
|
|
18
|
+
return input(prompt)
|
|
19
|
+
except NameError:
|
|
20
|
+
return raw_input(prompt) # noqa: F821
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def main():
|
|
24
|
+
parser = argparse.ArgumentParser(description="VAPID tool")
|
|
25
|
+
parser.add_argument("--sign", "-s", help="claims file to sign")
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"--gen", "-g", help="generate new key pairs", default=False, action="store_true"
|
|
28
|
+
)
|
|
29
|
+
parser.add_argument(
|
|
30
|
+
"--version2",
|
|
31
|
+
"-2",
|
|
32
|
+
help="use RFC8292 VAPID spec",
|
|
33
|
+
default=True,
|
|
34
|
+
action="store_true",
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
"--version1",
|
|
38
|
+
"-1",
|
|
39
|
+
help="use VAPID spec Draft-01",
|
|
40
|
+
default=False,
|
|
41
|
+
action="store_true",
|
|
42
|
+
)
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"--json", help="dump as json", default=False, action="store_true"
|
|
45
|
+
)
|
|
46
|
+
parser.add_argument(
|
|
47
|
+
"--no-strict",
|
|
48
|
+
help='Do not be strict about "sub"',
|
|
49
|
+
default=False,
|
|
50
|
+
action="store_true",
|
|
51
|
+
)
|
|
52
|
+
parser.add_argument(
|
|
53
|
+
"--applicationServerKey",
|
|
54
|
+
help="show applicationServerKey value",
|
|
55
|
+
default=False,
|
|
56
|
+
action="store_true",
|
|
57
|
+
)
|
|
58
|
+
parser.add_argument(
|
|
59
|
+
"--private-key", "-k", help="private key pem file", default="private_key.pem"
|
|
60
|
+
)
|
|
61
|
+
args = parser.parse_args()
|
|
62
|
+
|
|
63
|
+
# Added to solve 2.7 => 3.* incompatibility
|
|
64
|
+
# This library advocates for Vapid02
|
|
65
|
+
Vapid = Vapid02
|
|
66
|
+
if args.version1:
|
|
67
|
+
Vapid = Vapid01
|
|
68
|
+
if args.gen or not os.path.exists(args.private_key):
|
|
69
|
+
if not args.gen:
|
|
70
|
+
print("No private key file found.")
|
|
71
|
+
answer = None
|
|
72
|
+
while answer not in ["y", "n"]:
|
|
73
|
+
answer = prompt("Do you want me to create one for you? (Y/n)")
|
|
74
|
+
if not answer:
|
|
75
|
+
answer = "y"
|
|
76
|
+
answer = answer.lower()[0]
|
|
77
|
+
if answer == "n":
|
|
78
|
+
print("Sorry, can't do much for you then.")
|
|
79
|
+
exit(1)
|
|
80
|
+
|
|
81
|
+
vapid = Vapid(conf=vars(args))
|
|
82
|
+
vapid.generate_keys()
|
|
83
|
+
print("Generating private_key.pem")
|
|
84
|
+
vapid.save_key("private_key.pem")
|
|
85
|
+
print("Generating public_key.pem")
|
|
86
|
+
vapid.save_public_key("public_key.pem")
|
|
87
|
+
vapid = Vapid.from_file(args.private_key)
|
|
88
|
+
claim_file = args.sign
|
|
89
|
+
result = dict()
|
|
90
|
+
if args.applicationServerKey:
|
|
91
|
+
raw_pub = vapid.public_key.public_bytes(
|
|
92
|
+
serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint
|
|
93
|
+
)
|
|
94
|
+
print("Application Server Key = {}\n\n".format(b64urlencode(raw_pub)))
|
|
95
|
+
if claim_file:
|
|
96
|
+
if not os.path.exists(claim_file):
|
|
97
|
+
print("No {} file found.".format(claim_file))
|
|
98
|
+
print(
|
|
99
|
+
"""
|
|
100
|
+
The claims file should be a JSON formatted file that holds the
|
|
101
|
+
information that describes you. There are three elements in the claims
|
|
102
|
+
file you'll need:
|
|
103
|
+
|
|
104
|
+
"sub" This is your site's admin email address
|
|
105
|
+
(e.g. "mailto:admin@example.com")
|
|
106
|
+
"exp" This is the expiration time for the claim in seconds. If you don't
|
|
107
|
+
have one, I'll add one that expires in 24 hours.
|
|
108
|
+
|
|
109
|
+
You're also welcome to add additional fields to the claims which could be
|
|
110
|
+
helpful for the Push Service operations team to pass along to your operations
|
|
111
|
+
team (e.g. "ami-id": "e-123456", "cust-id": "a3sfa10987"). Remember to keep
|
|
112
|
+
these values short to prevent some servers from rejecting the transaction due
|
|
113
|
+
to overly large headers. See https://jwt.io/introduction/ for details.
|
|
114
|
+
|
|
115
|
+
For example, a claims.json file could contain:
|
|
116
|
+
|
|
117
|
+
{"sub": "mailto:admin@example.com"}
|
|
118
|
+
"""
|
|
119
|
+
)
|
|
120
|
+
exit(1)
|
|
121
|
+
try:
|
|
122
|
+
claims = json.loads(open(claim_file).read())
|
|
123
|
+
result.update(vapid.sign(claims))
|
|
124
|
+
except Exception as barrier:
|
|
125
|
+
print("Crap, something went wrong: {}".format(repr(barrier)))
|
|
126
|
+
raise barrier
|
|
127
|
+
if args.json:
|
|
128
|
+
print(json.dumps(result))
|
|
129
|
+
return
|
|
130
|
+
print("Include the following headers in your request:\n")
|
|
131
|
+
for key, value in result.items():
|
|
132
|
+
print("{}: {}\n".format(key, value))
|
|
133
|
+
print("\n")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
if __name__ == "__main__":
|
|
137
|
+
main()
|
py_vapid/jwt.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import binascii
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
from cryptography.exceptions import InvalidSignature
|
|
5
|
+
from cryptography.hazmat.primitives.asymmetric import ec, utils
|
|
6
|
+
from cryptography.hazmat.primitives import hashes
|
|
7
|
+
|
|
8
|
+
from py_vapid.utils import b64urldecode, b64urlencode, num_to_bytes
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def extract_signature(auth):
|
|
12
|
+
"""Extracts the payload and signature from a JWT, converting from RFC7518
|
|
13
|
+
to RFC 3279
|
|
14
|
+
|
|
15
|
+
:param auth: A JWT Authorization Token.
|
|
16
|
+
:type auth: str
|
|
17
|
+
|
|
18
|
+
:return tuple containing the signature material and signature
|
|
19
|
+
|
|
20
|
+
"""
|
|
21
|
+
payload, asig = auth.encode('utf8').rsplit(b'.', 1)
|
|
22
|
+
sig = b64urldecode(asig)
|
|
23
|
+
if len(sig) != 64:
|
|
24
|
+
raise InvalidSignature()
|
|
25
|
+
|
|
26
|
+
encoded = utils.encode_dss_signature(
|
|
27
|
+
s=int(binascii.hexlify(sig[32:]), 16),
|
|
28
|
+
r=int(binascii.hexlify(sig[:32]), 16)
|
|
29
|
+
)
|
|
30
|
+
return payload, encoded
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def decode(token, key):
|
|
34
|
+
"""Decode a web token into an assertion dictionary
|
|
35
|
+
|
|
36
|
+
:param token: VAPID auth token
|
|
37
|
+
:type token: str
|
|
38
|
+
:param key: bitarray containing the public key
|
|
39
|
+
:type key: str
|
|
40
|
+
|
|
41
|
+
:return dict of the VAPID claims
|
|
42
|
+
|
|
43
|
+
:raise InvalidSignature
|
|
44
|
+
|
|
45
|
+
"""
|
|
46
|
+
try:
|
|
47
|
+
sig_material, signature = extract_signature(token)
|
|
48
|
+
dkey = b64urldecode(key.encode('utf8'))
|
|
49
|
+
pkey = ec.EllipticCurvePublicKey.from_encoded_point(
|
|
50
|
+
ec.SECP256R1(),
|
|
51
|
+
dkey,
|
|
52
|
+
)
|
|
53
|
+
pkey.verify(
|
|
54
|
+
signature,
|
|
55
|
+
sig_material,
|
|
56
|
+
ec.ECDSA(hashes.SHA256())
|
|
57
|
+
)
|
|
58
|
+
return json.loads(
|
|
59
|
+
b64urldecode(sig_material.split(b'.')[1]).decode('utf8')
|
|
60
|
+
)
|
|
61
|
+
except InvalidSignature:
|
|
62
|
+
raise
|
|
63
|
+
except(ValueError, TypeError, binascii.Error):
|
|
64
|
+
raise InvalidSignature()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def sign(claims, key):
|
|
68
|
+
"""Sign the claims
|
|
69
|
+
|
|
70
|
+
:param claims: list of JWS claims
|
|
71
|
+
:type claims: dict
|
|
72
|
+
:param key: Private key for signing
|
|
73
|
+
:type key: ec.EllipticCurvePrivateKey
|
|
74
|
+
:param algorithm: JWT "alg" descriptor
|
|
75
|
+
:type algorithm: str
|
|
76
|
+
|
|
77
|
+
"""
|
|
78
|
+
header = b64urlencode(b"""{"typ":"JWT","alg":"ES256"}""")
|
|
79
|
+
# Unfortunately, chrome seems to require the claims to be sorted.
|
|
80
|
+
claims = b64urlencode(json.dumps(claims,
|
|
81
|
+
separators=(',', ':'),
|
|
82
|
+
sort_keys=True).encode('utf8'))
|
|
83
|
+
token = "{}.{}".format(header, claims)
|
|
84
|
+
rsig = key.sign(token.encode('utf8'), ec.ECDSA(hashes.SHA256()))
|
|
85
|
+
(r, s) = utils.decode_dss_signature(rsig)
|
|
86
|
+
sig = b64urlencode(num_to_bytes(r, 32) + num_to_bytes(s, 32))
|
|
87
|
+
return "{}.{}".format(token, sig)
|