py-vapid 1.9.2__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 ADDED
@@ -0,0 +1,391 @@
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
+ pass
29
+
30
+
31
+ class Vapid01(object):
32
+ """Minimal VAPID Draft 01 signature generation library.
33
+
34
+ https://tools.ietf.org/html/draft-ietf-webpush-vapid-01
35
+
36
+ """
37
+ _private_key = None
38
+ _public_key = None
39
+ _schema = "WebPush"
40
+
41
+ def __init__(self, private_key=None, conf=None):
42
+ """Initialize VAPID with an optional private key.
43
+
44
+ :param private_key: A private key object
45
+ :type private_key: ec.EllipticCurvePrivateKey
46
+
47
+ """
48
+ if conf is None:
49
+ conf = {}
50
+ self.conf = conf
51
+ self.private_key = private_key
52
+ if private_key:
53
+ self._public_key = self.private_key.public_key()
54
+
55
+ @classmethod
56
+ def from_raw(cls, private_raw):
57
+ """Initialize VAPID using a private key point in "raw" or
58
+ "uncompressed" form. Raw keys consist of a single, 32 octet
59
+ encoded integer.
60
+
61
+ :param private_raw: A private key point in uncompressed form.
62
+ :type private_raw: bytes
63
+
64
+ """
65
+ key = ec.derive_private_key(
66
+ int(binascii.hexlify(b64urldecode(private_raw)), 16),
67
+ curve=ec.SECP256R1(),
68
+ backend=default_backend())
69
+ return cls(key)
70
+
71
+ @classmethod
72
+ def from_raw_public(cls, public_raw):
73
+ key = ec.EllipticCurvePublicKey.from_encoded_point(
74
+ curve=ec.SECP256R1(),
75
+ data=b64urldecode(public_raw)
76
+ )
77
+ ss = cls()
78
+ ss._public_key = key
79
+ return ss
80
+
81
+ @classmethod
82
+ def from_pem(cls, private_key):
83
+ """Initialize VAPID using a private key in PEM format.
84
+
85
+ :param private_key: A private key in PEM format.
86
+ :type private_key: bytes
87
+
88
+ """
89
+ # not sure why, but load_pem_private_key fails to deserialize
90
+ return cls.from_der(
91
+ b''.join(private_key.splitlines()[1:-1]))
92
+
93
+ @classmethod
94
+ def from_der(cls, private_key):
95
+ """Initialize VAPID using a private key in DER format.
96
+
97
+ :param private_key: A private key in DER format and Base64-encoded.
98
+ :type private_key: bytes
99
+
100
+ """
101
+ key = serialization.load_der_private_key(b64urldecode(private_key),
102
+ password=None,
103
+ backend=default_backend())
104
+ return cls(key)
105
+
106
+ @classmethod
107
+ def from_file(cls, private_key_file=None):
108
+ """Initialize VAPID using a file containing a private key in PEM or
109
+ DER format.
110
+
111
+ :param private_key_file: Name of the file containing the private key
112
+ :type private_key_file: str
113
+
114
+ """
115
+ if not os.path.isfile(private_key_file):
116
+ logging.info("Private key not found, generating key...")
117
+ vapid = cls()
118
+ vapid.generate_keys()
119
+ vapid.save_key(private_key_file)
120
+ return vapid
121
+ with open(private_key_file, 'r') as file:
122
+ private_key = file.read()
123
+ try:
124
+ if "-----BEGIN" in private_key:
125
+ vapid = cls.from_pem(private_key.encode('utf8'))
126
+ else:
127
+ vapid = cls.from_der(private_key.encode('utf8'))
128
+ return vapid
129
+ except Exception as exc:
130
+ logging.error("Could not open private key file: %s", repr(exc))
131
+ raise VapidException(exc)
132
+
133
+ @classmethod
134
+ def from_string(cls, private_key):
135
+ """Initialize VAPID using a string containing the private key. This
136
+ will try to determine if the key is in RAW or DER format.
137
+
138
+ :param private_key: String containing the key info
139
+ :type private_key: str
140
+
141
+ """
142
+
143
+ pkey = private_key.encode().replace(b"\n", b"")
144
+ key = b64urldecode(pkey)
145
+ if len(key) == 32:
146
+ return cls.from_raw(pkey)
147
+ return cls.from_der(pkey)
148
+
149
+ @classmethod
150
+ def verify(cls, key, auth):
151
+ """Verify a VAPID authorization token.
152
+
153
+ :param key: base64 serialized public key
154
+ :type key: str
155
+ :param auth: authorization token
156
+ type key: str
157
+
158
+ """
159
+ tokens = auth.rsplit(' ', 1)[1].rsplit('.', 1)
160
+ kp = cls().from_raw_public(key.encode())
161
+ return kp.verify_token(
162
+ validation_token=tokens[0].encode(),
163
+ 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,
201
+ default_backend())
202
+
203
+ def private_pem(self):
204
+ return self.private_key.private_bytes(
205
+ encoding=serialization.Encoding.PEM,
206
+ format=serialization.PrivateFormat.PKCS8,
207
+ encryption_algorithm=serialization.NoEncryption()
208
+ )
209
+
210
+ def public_pem(self):
211
+ return self.public_key.public_bytes(
212
+ encoding=serialization.Encoding.PEM,
213
+ format=serialization.PublicFormat.SubjectPublicKeyInfo
214
+ )
215
+
216
+ def save_key(self, key_file):
217
+ """Save the private key to a PEM file.
218
+
219
+ :param key_file: The file path to save the private key data
220
+ :type key_file: str
221
+
222
+ """
223
+ with open(key_file, "wb") as file:
224
+ file.write(self.private_pem())
225
+ file.close()
226
+
227
+ def save_public_key(self, key_file):
228
+ """Save the public key to a PEM file.
229
+ :param key_file: The name of the file to save the public key
230
+ :type key_file: str
231
+
232
+ """
233
+ with open(key_file, "wb") as file:
234
+ file.write(self.public_pem())
235
+ file.close()
236
+
237
+ def verify_token(self, validation_token, verification_token):
238
+ """Internally used to verify the verification token is correct.
239
+
240
+ :param validation_token: Provided validation token string
241
+ :type validation_token: str
242
+ :param verification_token: Generated verification token
243
+ :type verification_token: str
244
+ :returns: Boolean indicating if verifictation token is valid.
245
+ :rtype: boolean
246
+
247
+ """
248
+ hsig = b64urldecode(verification_token.encode('utf8'))
249
+ r = int(binascii.hexlify(hsig[:32]), 16)
250
+ s = int(binascii.hexlify(hsig[32:]), 16)
251
+ try:
252
+ self.public_key.verify(
253
+ ecutils.encode_dss_signature(r, s),
254
+ validation_token,
255
+ signature_algorithm=ec.ECDSA(hashes.SHA256())
256
+ )
257
+ return True
258
+ except InvalidSignature:
259
+ return False
260
+
261
+ def _base_sign(self, claims):
262
+ cclaims = copy.deepcopy(claims)
263
+ if not cclaims.get('exp'):
264
+ cclaims['exp'] = int(time.time()) + 86400
265
+ if not self.conf.get('no-strict', False):
266
+ valid = _check_sub(cclaims.get('sub', ''))
267
+ else:
268
+ valid = cclaims.get('sub') is not None
269
+ if not valid:
270
+ raise VapidException(
271
+ "Missing 'sub' from claims. "
272
+ "'sub' is your admin email as a mailto: link.")
273
+ if not re.match(r"^https?://[^/:]+(:\d+)?$",
274
+ cclaims.get("aud", ""),
275
+ re.IGNORECASE):
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
+ return cclaims
281
+
282
+ def sign(self, claims, crypto_key=None):
283
+ """Sign a set of claims.
284
+ :param claims: JSON object containing the JWT claims to use.
285
+ :type claims: dict
286
+ :param crypto_key: Optional existing crypto_key header content. The
287
+ vapid public key will be appended to this data.
288
+ :type crypto_key: str
289
+ :returns: a hash containing the header fields to use in
290
+ the subscription update.
291
+ :rtype: dict
292
+
293
+ """
294
+ sig = sign(self._base_sign(claims), self.private_key)
295
+ pkey = 'p256ecdsa='
296
+ pkey += b64urlencode(
297
+ self.public_key.public_bytes(
298
+ serialization.Encoding.X962,
299
+ serialization.PublicFormat.UncompressedPoint
300
+ ))
301
+ if crypto_key:
302
+ crypto_key = crypto_key + ';' + pkey
303
+ else:
304
+ crypto_key = pkey
305
+
306
+ return {"Authorization": "{} {}".format(self._schema, sig.strip('=')),
307
+ "Crypto-Key": crypto_key}
308
+
309
+
310
+ class Vapid02(Vapid01):
311
+ """Minimal Vapid RFC8292 signature generation library
312
+
313
+ https://tools.ietf.org/html/rfc8292
314
+
315
+ """
316
+ _schema = "vapid"
317
+
318
+ def sign(self, claims, crypto_key=None):
319
+ """Generate an authorization token
320
+
321
+ :param claims: JSON object containing the JWT claims to use.
322
+ :type claims: dict
323
+ :param crypto_key: Optional existing crypto_key header content. The
324
+ vapid public key will be appended to this data.
325
+ :type crypto_key: str
326
+ :returns: a hash containing the header fields to use in
327
+ the subscription update.
328
+ :rtype: dict
329
+ """
330
+ sig = sign(self._base_sign(claims), self.private_key)
331
+ pkey = self.public_key.public_bytes(
332
+ serialization.Encoding.X962,
333
+ serialization.PublicFormat.UncompressedPoint
334
+ )
335
+ return{
336
+ "Authorization": "{schema} t={t},k={k}".format(
337
+ schema=self._schema,
338
+ t=sig,
339
+ k=b64urlencode(pkey)
340
+ )
341
+ }
342
+
343
+ @classmethod
344
+ def verify(cls, auth):
345
+ """Ensure that the token is correctly formatted and valid
346
+
347
+ :param auth: An Authorization header
348
+ :type auth: str
349
+ :rtype: bool
350
+
351
+ """
352
+ pref_tok = auth.rsplit(' ', 1)
353
+ assert pref_tok[0].lower() == cls._schema, (
354
+ "Incorrect schema specified")
355
+ parts = {}
356
+ for tok in pref_tok[1].split(','):
357
+ kv = tok.split('=', 1)
358
+ parts[kv[0]] = kv[1]
359
+ assert 'k' in parts.keys(), (
360
+ "Auth missing public key 'k' value")
361
+ assert 't' in parts.keys(), (
362
+ "Auth missing token set 't' value")
363
+ kp = cls().from_raw_public(parts['k'].encode())
364
+ tokens = parts['t'].rsplit('.', 1)
365
+ return kp.verify_token(
366
+ validation_token=tokens[0].encode(),
367
+ verification_token=tokens[1]
368
+ )
369
+
370
+
371
+ def _check_sub(sub):
372
+ """ Check to see if the `sub` is a properly formatted `mailto:`
373
+
374
+ a `mailto:` should be a SMTP mail address. Mind you, since I run
375
+ YouFailAtEmail.com, you have every right to yell about how terrible
376
+ this check is. I really should be doing a proper component parse
377
+ and valiate each component individually per RFC5341, instead I do
378
+ the unholy regex you see below.
379
+
380
+ :param sub: Candidate JWT `sub`
381
+ :type sub: str
382
+ :rtype: bool
383
+
384
+ """
385
+ pattern = (
386
+ 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
387
+ )
388
+ return re.match(pattern, sub, re.IGNORECASE) is not None
389
+
390
+
391
+ Vapid = Vapid02
py_vapid/__main__.py ADDED
@@ -0,0 +1,135 @@
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
+ Vapid = Vapid02
65
+ if args.version1:
66
+ Vapid = Vapid01
67
+ if args.gen or not os.path.exists(args.private_key):
68
+ if not args.gen:
69
+ print("No private key file found.")
70
+ answer = None
71
+ while answer not in ["y", "n"]:
72
+ answer = prompt("Do you want me to create one for you? (Y/n)")
73
+ if not answer:
74
+ answer = "y"
75
+ answer = answer.lower()[0]
76
+ if answer == "n":
77
+ print("Sorry, can't do much for you then.")
78
+ exit(1)
79
+ vapid = Vapid(conf=args)
80
+ vapid.generate_keys()
81
+ print("Generating private_key.pem")
82
+ vapid.save_key("private_key.pem")
83
+ print("Generating public_key.pem")
84
+ vapid.save_public_key("public_key.pem")
85
+ vapid = Vapid.from_file(args.private_key)
86
+ claim_file = args.sign
87
+ result = dict()
88
+ if args.applicationServerKey:
89
+ raw_pub = vapid.public_key.public_bytes(
90
+ serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint
91
+ )
92
+ print("Application Server Key = {}\n\n".format(b64urlencode(raw_pub)))
93
+ if claim_file:
94
+ if not os.path.exists(claim_file):
95
+ print("No {} file found.".format(claim_file))
96
+ print(
97
+ """
98
+ The claims file should be a JSON formatted file that holds the
99
+ information that describes you. There are three elements in the claims
100
+ file you'll need:
101
+
102
+ "sub" This is your site's admin email address
103
+ (e.g. "mailto:admin@example.com")
104
+ "exp" This is the expiration time for the claim in seconds. If you don't
105
+ have one, I'll add one that expires in 24 hours.
106
+
107
+ You're also welcome to add additional fields to the claims which could be
108
+ helpful for the Push Service operations team to pass along to your operations
109
+ team (e.g. "ami-id": "e-123456", "cust-id": "a3sfa10987"). Remember to keep
110
+ these values short to prevent some servers from rejecting the transaction due
111
+ to overly large headers. See https://jwt.io/introduction/ for details.
112
+
113
+ For example, a claims.json file could contain:
114
+
115
+ {"sub": "mailto:admin@example.com"}
116
+ """
117
+ )
118
+ exit(1)
119
+ try:
120
+ claims = json.loads(open(claim_file).read())
121
+ result.update(vapid.sign(claims))
122
+ except Exception as exc:
123
+ print("Crap, something went wrong: {}".format(repr(exc)))
124
+ raise exc
125
+ if args.json:
126
+ print(json.dumps(result))
127
+ return
128
+ print("Include the following headers in your request:\n")
129
+ for key, value in result.items():
130
+ print("{}: {}\n".format(key, value))
131
+ print("\n")
132
+
133
+
134
+ if __name__ == "__main__":
135
+ 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)