Skip to content

Commit bddefbe

Browse files
authored
Merge pull request #42 from web-push-libs/crypto
feat: Switch to cryptography library
2 parents e3e0aaa + a3e1137 commit bddefbe

File tree

7 files changed

+319
-122
lines changed

7 files changed

+319
-122
lines changed

python/README.md

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ fields.
1414

1515
At a minimum a VAPID claim set should look like:
1616
```
17-
{"sub":"mailto:[email protected]","aud":"https://PushServerURL","exp":"ExpirationTimestamp"}
17+
{"sub":"mailto:[email protected]","aud":"https://PushServer","exp":"ExpirationTimestamp"}
1818
```
1919
A few notes:
2020

@@ -24,28 +24,28 @@ email that will be used to contact you (for instance). This can be a
2424
general delivery address like "`mailto:[email protected]`" or a
2525
specific address like "`mailto:[email protected]`".
2626

27-
***aud*** is the audience for the VAPID. This it the host path you use to
28-
send subscription endpoints and generally coincides with the
29-
`endpoint` specified in the Subscription Info block.
27+
***aud*** is the audience for the VAPID. This is the scheme and host
28+
you use to send subscription endpoints and generally coincides with
29+
the `endpoint` specified in the Subscription Info block.
3030

3131
As example, if a WebPush subscription info contains:
3232
`{"endpoint": "https://push.example.com:8012/v1/push/...", ...}`
3333

34-
then the `aud` would be "`https://push.example.com:8012/`"
34+
then the `aud` would be "`https://push.example.com:8012`"
3535

3636
While some Push Services consider this an optional field, others may
3737
be stricter.
3838

3939
***exp*** This is the UTC timestamp for when this VAPID request will
4040
expire. The maximum period is 24 hours. Setting a shorter period can
4141
prevent "replay" attacks. Setting a longer period allows you to reuse
42-
headers for multiple sends (e.g. if you're sending hundreds of updates
42+
headers for multiple sends (e.g. if you're sending hundreds of updates
4343
within an hour or so.) If no `exp` is included, one that will expire
4444
in 24 hours will be auto-generated for you.
4545

4646
Claims should be stored in a JSON compatible file. In the examples
47-
below, we've stored the claims into a file named `claims.json`.
48-
47+
below, we've stored the claims into a file named `claims.json`.
48+
4949
py_vapid can either be installed as a library or used as a stand along
5050
app, `bin/vapid`.
5151

@@ -64,9 +64,9 @@ bin/python setup.py install
6464
Run by itself, `bin/vapid` will check and optionally create the
6565
public_key.pem and private_key.pem files.
6666

67-
`bin/vapid -gen` can be used to generate a new set of public and
68-
private key PEM files. These will overwrite the contents of
69-
`private_key.pem` and `public_key.pem`.
67+
`bin/vapid -gen` can be used to generate a new set of public and
68+
private key PEM files. These will overwrite the contents of
69+
`private_key.pem` and `public_key.pem`.
7070

7171
`bin/vapid --sign claims.json` will generate a set of HTTP headers
7272
from a JSON formatted claims file. A sample `claims.json` is included

python/py_vapid/__init__.py

Lines changed: 84 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -5,26 +5,20 @@
55
import os
66
import logging
77
import binascii
8-
import base64
98
import time
10-
import hashlib
9+
import re
1110

12-
import ecdsa
13-
from jose import jws
11+
from cryptography.hazmat.backends import default_backend
12+
from cryptography.hazmat.primitives.asymmetric import ec
13+
from cryptography.hazmat.primitives import serialization
1414

15-
# Show compliance version. For earlier versions see previously tagged releases.
16-
VERSION = "VAPID-DRAFT-02/ECE-DRAFT-07"
17-
18-
19-
def b64urldecode(data):
20-
"""Decodes an unpadded Base64url-encoded string."""
21-
return base64.urlsafe_b64decode(data + "===="[:len(data) % 4])
15+
from cryptography.hazmat.primitives import hashes
2216

17+
from py_vapid.utils import b64urldecode, b64urlencode
18+
from py_vapid.jwt import sign
2319

24-
def b64urlencode(bstring):
25-
return binascii.b2a_base64(
26-
bstring).decode('utf8').replace('\n', '').replace(
27-
'+', '-').replace('/', '_').replace('=', '')
20+
# Show compliance version. For earlier versions see previously tagged releases.
21+
VERSION = "VAPID-DRAFT-02/ECE-DRAFT-07"
2822

2923

3024
class VapidException(Exception):
@@ -40,8 +34,6 @@ class Vapid01(object):
4034
"""
4135
_private_key = None
4236
_public_key = None
43-
_curve = ecdsa.NIST256p
44-
_hasher = hashlib.sha256
4537
_schema = "WebPush"
4638

4739
def __init__(self, private_key=None):
@@ -52,41 +44,48 @@ def __init__(self, private_key=None):
5244
5345
"""
5446
self.private_key = private_key
47+
if private_key:
48+
self._public_key = self.private_key.public_key()
5549

5650
@classmethod
57-
def from_raw(cls, private_key):
51+
def from_raw(cls, private_raw):
5852
"""Initialize VAPID using a private key point in "raw" or
59-
"uncompressed" form.
53+
"uncompressed" form. Raw keys consist of a single, 32 octet
54+
encoded integer.
6055
61-
:param private_key: A private key point in uncompressed form.
62-
:type private_key: str
56+
:param private_raw: A private key point in uncompressed form.
57+
:type private_raw: bytes
6358
6459
"""
65-
key = ecdsa.SigningKey.from_string(b64urldecode(private_key),
66-
curve=cls._curve,
67-
hashfunc=cls._hasher)
60+
key = ec.derive_private_key(
61+
int(binascii.hexlify(b64urldecode(private_raw)), 16),
62+
curve=ec.SECP256R1(),
63+
backend=default_backend())
6864
return cls(key)
6965

7066
@classmethod
7167
def from_pem(cls, private_key):
7268
"""Initialize VAPID using a private key in PEM format.
7369
7470
:param private_key: A private key in PEM format.
75-
:type private_key: str
71+
:type private_key: bytes
7672
7773
"""
78-
key = ecdsa.SigningKey.from_pem(private_key)
79-
return cls(key)
74+
# not sure why, but load_pem_private_key fails to deserialize
75+
return cls.from_der(
76+
b''.join(private_key.splitlines()[1:-1]))
8077

8178
@classmethod
8279
def from_der(cls, private_key):
8380
"""Initialize VAPID using a private key in DER format.
8481
8582
:param private_key: A private key in DER format and Base64-encoded.
86-
:type private_key: str
83+
:type private_key: bytes
8784
8885
"""
89-
key = ecdsa.SigningKey.from_der(base64.b64decode(private_key))
86+
key = serialization.load_der_private_key(b64urldecode(private_key),
87+
password=None,
88+
backend=default_backend())
9089
return cls(key)
9190

9291
@classmethod
@@ -100,54 +99,69 @@ def from_file(cls, private_key_file=None):
10099
"""
101100
if not os.path.isfile(private_key_file):
102101
vapid = cls()
102+
vapid.generate_keys()
103103
vapid.save_key(private_key_file)
104104
return vapid
105105
private_key = open(private_key_file, 'r').read()
106-
vapid = None
107106
try:
108-
if "BEGIN EC" in private_key:
109-
vapid = cls.from_pem(private_key)
107+
if "-----BEGIN" in private_key:
108+
vapid = cls.from_pem(private_key.encode('utf8'))
110109
else:
111-
vapid = cls.from_der(private_key)
110+
vapid = cls.from_der(private_key.encode('utf8'))
111+
return vapid
112112
except Exception as exc:
113113
logging.error("Could not open private key file: %s", repr(exc))
114114
raise VapidException(exc)
115-
return vapid
116115

117116
@property
118117
def private_key(self):
119118
"""The VAPID private ECDSA key"""
120119
if not self._private_key:
121-
raise VapidException(
122-
"No private key defined. Please import or generate a key.")
120+
raise VapidException("No private key. Call generate_keys()")
123121
return self._private_key
124122

125123
@private_key.setter
126124
def private_key(self, value):
127125
"""Set the VAPID private ECDSA key
128126
129127
:param value: the byte array containing the private ECDSA key data
130-
:type value: bytes
128+
:type value: ec.EllipticCurvePrivateKey
131129
132130
"""
133131
self._private_key = value
134-
self._public_key = None
132+
if value:
133+
self._public_key = self.private_key.public_key()
135134

136135
@property
137136
def public_key(self):
138137
"""The VAPID public ECDSA key
139138
140139
The public key is currently read only. Set it via the `.private_key`
141-
method.
140+
method. This will autogenerate a public and private key if no value
141+
has been set.
142+
143+
:returns ec.EllipticCurvePublicKey
142144
143145
"""
144-
if not self._public_key:
145-
self._public_key = self.private_key.get_verifying_key()
146146
return self._public_key
147147

148148
def generate_keys(self):
149149
"""Generate a valid ECDSA Key Pair."""
150-
self.private_key = ecdsa.SigningKey.generate(curve=self._curve)
150+
self.private_key = ec.generate_private_key(ec.SECP256R1,
151+
default_backend())
152+
153+
def private_pem(self):
154+
return self.private_key.private_bytes(
155+
encoding=serialization.Encoding.PEM,
156+
format=serialization.PrivateFormat.PKCS8,
157+
encryption_algorithm=serialization.NoEncryption()
158+
)
159+
160+
def public_pem(self):
161+
return self.public_key.public_bytes(
162+
encoding=serialization.Encoding.PEM,
163+
format=serialization.PublicFormat.SubjectPublicKeyInfo
164+
)
151165

152166
def save_key(self, key_file):
153167
"""Save the private key to a PEM file.
@@ -156,11 +170,9 @@ def save_key(self, key_file):
156170
:type key_file: str
157171
158172
"""
159-
file = open(key_file, "wb")
160-
if not self._private_key:
161-
self.generate_keys()
162-
file.write(self._private_key.to_pem())
163-
file.close()
173+
with open(key_file, "wb") as file:
174+
file.write(self.private_pem())
175+
file.close()
164176

165177
def save_public_key(self, key_file):
166178
"""Save the public key to a PEM file.
@@ -169,7 +181,7 @@ def save_public_key(self, key_file):
169181
170182
"""
171183
with open(key_file, "wb") as file:
172-
file.write(self.public_key.to_pem())
184+
file.write(self.public_pem())
173185
file.close()
174186

175187
def validate(self, validation_token):
@@ -183,8 +195,8 @@ def validate(self, validation_token):
183195
"""
184196
sig = self.private_key.sign(
185197
validation_token,
186-
hashfunc=self._hasher)
187-
verification_token = base64.urlsafe_b64encode(sig)
198+
signature_algorithm=ec.ECDSA(hashes.SHA256()))
199+
verification_token = b64urlencode(sig)
188200
return verification_token
189201

190202
def verify_token(self, validation_token, verification_token):
@@ -198,17 +210,30 @@ def verify_token(self, validation_token, verification_token):
198210
:rtype: boolean
199211
200212
"""
201-
hsig = base64.urlsafe_b64decode(verification_token)
202-
return self.public_key.verify(hsig, validation_token,
203-
hashfunc=self._hasher)
213+
hsig = b64urldecode(verification_token.encode('utf8'))
214+
return self.public_key.verify(
215+
hsig,
216+
validation_token,
217+
signature_algorithm=ec.ECDSA(hashes.SHA256())
218+
)
204219

205220
def _base_sign(self, claims):
206221
if not claims.get('exp'):
207222
claims['exp'] = str(int(time.time()) + 86400)
208-
if not claims.get('sub'):
223+
if not re.match("mailto:.+@.+\..+",
224+
claims.get('sub', ''),
225+
re.IGNORECASE):
209226
raise VapidException(
210227
"Missing 'sub' from claims. "
211228
"'sub' is your admin email as a mailto: link.")
229+
if not re.match("^https?:\/\/[^\/\.:]+\.[^\/:]+(:\d+)?$",
230+
claims.get("aud", ""),
231+
re.IGNORECASE):
232+
raise VapidException(
233+
"Missing 'aud' from claims. "
234+
"'aud' is the scheme, host and optional port for this "
235+
"transaction e.g. https://example.com:8080")
236+
212237
return claims
213238

214239
def sign(self, claims, crypto_key=None):
@@ -224,12 +249,10 @@ def sign(self, claims, crypto_key=None):
224249
225250
"""
226251
claims = self._base_sign(claims)
227-
sig = jws.sign(claims, self.private_key, algorithm="ES256")
252+
sig = sign(claims, self.private_key)
228253
pkey = 'p256ecdsa='
229-
pubkey = self.public_key.to_string()
230-
if len(pubkey) == 64:
231-
pubkey = b'\04' + pubkey
232-
pkey += b64urlencode(pubkey)
254+
pkey += b64urlencode(
255+
self.public_key.public_numbers().encode_point())
233256
if crypto_key:
234257
crypto_key = crypto_key + ';' + pkey
235258
else:
@@ -249,11 +272,8 @@ class Vapid02(Vapid01):
249272

250273
def sign(self, claims, crypto_key=None):
251274
claims = self._base_sign(claims)
252-
sig = jws.sign(claims, self.private_key, algorithm="ES256")
253-
pkey = self.public_key.to_string()
254-
# Make sure that the key is properly prefixed.
255-
if len(pkey) == 64:
256-
pkey = b'\04' + pkey
275+
sig = sign(claims, self.private_key)
276+
pkey = self.public_key.public_numbers().encode_point()
257277
return{
258278
"Authorization": "{schema} t={t},k={k}".format(
259279
schema=self._schema,

0 commit comments

Comments
 (0)