From cf00be8a53276899d913af3ecf994c17fa79c777 Mon Sep 17 00:00:00 2001 From: jrconlin Date: Thu, 18 Feb 2016 10:42:52 -0800 Subject: [PATCH] feature: allow channels to register with public key Channels can include a public key when registering a new subscription channel. This public key should match the public key used to send subscription updates later. NOTE: this patch changes the format of the endpoint URLs, & the content of the endpoint URL token. This change also requires that ChannelIDs be normalized to dashed format, (e.g. a lower case, dash delimited string "deadbeef-0000-0000-deca-fbad11112222") This is the default mechanism used by Firefox for UUID generation. It is STRONGLY urged that clients normalize UUIDs used for ChannelIDs and User Agent IDs. While this should not break existing clients, additional testing may be required. Closes: #326 --- autopush/endpoint.py | 54 ++++++++++++++----------- autopush/settings.py | 70 +++++++++++++++++++++++++++++++-- autopush/tests/test_endpoint.py | 2 +- 3 files changed, 97 insertions(+), 29 deletions(-) diff --git a/autopush/endpoint.py b/autopush/endpoint.py index 0f81d568..2d89169f 100644 --- a/autopush/endpoint.py +++ b/autopush/endpoint.py @@ -303,7 +303,7 @@ def _invalid_auth(self, fail): log.msg("Invalid bearer token: " + message, **self._client_info) raise VapidAuthException("Invalid bearer token: " + message) - def _process_auth(self, result): + def _process_auth(self, result, keys): """Process the optional VAPID auth token. VAPID requires two headers to be present; @@ -317,28 +317,25 @@ def _process_auth(self, result): if not authorization: return result - header_info = parse_header(self.request.headers.get('crypto-key')) - if not header_info: + if keys is None: raise VapidAuthException("Missing Crypto-Key") - values = header_info[-1] - if isinstance(values, dict): - crypto_key = values.get('p256ecdsa') - try: - (auth_type, token) = authorization.split(' ', 1) - except ValueError: - raise VapidAuthException("Invalid Authorization header") - # if it's a bearer token containing what may be a JWT - if auth_type.lower() == AUTH_SCHEME and '.' in token: - d = deferToThread(extract_jwt, token, crypto_key) - d.addCallback(self._store_auth, crypto_key, token, result) - d.addErrback(self._invalid_auth) - return d - # otherwise, it's not, so ignore the VAPID data. - return result - else: + + crypto_key = keys.get('p256ecdsa') + if not crypto_key: raise VapidAuthException("Invalid bearer token: " "improperly specified crypto-key") - + try: + (auth_type, token) = authorization.split(' ', 1) + except ValueError: + raise VapidAuthException("Invalid Authorization header") + # if it's a bearer token containing what may be a JWT + if auth_type.lower() == AUTH_SCHEME and '.' in token: + d = deferToThread(extract_jwt, token, crypto_key) + d.addCallback(self._store_auth, crypto_key, token, result) + d.addErrback(self._invalid_auth) + return d + # otherwise, it's not, so ignore the VAPID data. + return result class MessageHandler(AutoendpointHandler): cors_methods = "DELETE" @@ -405,10 +402,19 @@ def put(self, token): """ self.start_time = time.time() - fernet = self.ap_settings.fernet - - d = deferToThread(fernet.decrypt, token.encode('utf8')) - d.addCallback(self._process_auth) + public_key = None + keys = {} + crypto_key = self.request.headers.get('crypto-key') + if crypto_key: + header_info = parse_header(crypto_key) + keys = header_info[-1] + if isinstance(keys, dict): + public_key = keys.get('p256ecdsa') + + d = deferToThread(self.ap_settings.parse_endpoint, + token, + public_key) + d.addCallback(self._process_auth, keys) d.addCallback(self._token_valid) d.addErrback(self._auth_err) d.addErrback(self._token_err) diff --git a/autopush/settings.py b/autopush/settings.py index 8a8daec4..d65bafa0 100644 --- a/autopush/settings.py +++ b/autopush/settings.py @@ -2,6 +2,8 @@ import datetime import socket +from hashlib import sha256 + from cryptography.fernet import Fernet, MultiFernet from twisted.internet import reactor from twisted.internet.defer import ( @@ -248,7 +250,67 @@ def update(self, **kwargs): else: setattr(self, key, val) - def make_endpoint(self, uaid, chid): - """ Create an endpoint from the identifiers""" - return self.endpoint_url + '/push/' + \ - self.fernet.encrypt((uaid + ':' + chid).encode('utf8')) + def make_endpoint(self, uaid, chid, key=None): + """Create an v1 or v2 endpoint from the indentifiers. + + Both endpoints use bytes instead of hex to reduce ID length. + v0 is uaid.hex + ':' + chid.hex and is deprecated. + v1 is the uaid + chid + v2 is the uaid + chid + sha256(key).bytes + + :param uaid: User Agent Identifier + :param chid: Channel or Subscription ID + :param key: Optional provided Public Key + :returns: Push endpoint + + """ + root = self.endpoint_url + '/push/' + base = uaid.decode("hex") + chid.decode("hex") + + if key is None: + return root + 'v1/' + self.fernet.encrypt(base).strip('=') + + return root + 'v2/' + self.fernet.encrypt(base + sha256(key).digest()) + + def parse_endpoint(self, token, public_key=None): + """Parse an endpoint into component elements of UAID, CHID and optional + key hash if v2 + + :param endpoint: this is either a path string or a full URL that will + be parsed into a path string. + :param public_key: the public key (from Encryption-Key: p256ecdsa=) + + :raises ValueError: In the case of a malformed endpoint. + + :returns: a tuple containing the (UAID, CHID, Public Key hash) + + """ + import pdb;pdb.set_trace() + path = token.split('/') + if path[0] is '' and len(path) > 1: + path = path[:1] + if len(path) == 1: + ver = 'v0' + token = path[0].encode('utf8') + else: + ver = path[0] + token = path[1] + + d = deferToThread(self.fernet.decrypt, token) + d.addCallback(self._handle_tokens, ver, public_key) + return d + + def _handle_tokens(self, token, version='v0', public_key=None): + if version is 'v0': + if ':' not in token: + raise ValueError("Corrupted push token") + return token.split(':') + import pdb;pdb.set_trace() + if version is 'v1' and len(token) != 32: + raise ValueError("Corrupted push token") + if version is 'v2': + if len(token) != 64: + raise ValueError("Corrupted push token") + if sha256(public_key).digest is not token[32:]: + raise ValueError("Key Mismatch") + return (token[:16].encode('hex'), token[16:32].encode('hex')) diff --git a/autopush/tests/test_endpoint.py b/autopush/tests/test_endpoint.py index cb0974de..e09a6762 100644 --- a/autopush/tests/test_endpoint.py +++ b/autopush/tests/test_endpoint.py @@ -182,7 +182,7 @@ def setUp(self, t): # so slow, that many of these tests will time out leading # to false failure rates and integration tests generally # failing. - self.timeout = 1 + #self.timeout = 1 twisted.internet.base.DelayedCall.debug = True