class JWE(object): """JSON Web Encryption object This object represent a JWE token. """ def __init__(self, plaintext=None, protected=None, unprotected=None, aad=None, algs=None, recipient=None, header=None, header_registry=None): """Creates a JWE token. :param plaintext(bytes): An arbitrary plaintext to be encrypted. :param protected: A JSON string with the protected header. :param unprotected: A JSON string with the shared unprotected header. :param aad(bytes): Arbitrary additional authenticated data :param algs: An optional list of allowed algorithms :param recipient: An optional, default recipient key :param header: An optional header for the default recipient :param header_registry: Optional additions to the header registry """ self._allowed_algs = None self.objects = dict() self.plaintext = None self.header_registry = JWSEHeaderRegistry(JWEHeaderRegistry) if header_registry: self.header_registry.update(header_registry) if plaintext is not None: if isinstance(plaintext, bytes): self.plaintext = plaintext else: self.plaintext = plaintext.encode('utf-8') self.cek = None self.decryptlog = None if aad: self.objects['aad'] = aad if protected: if isinstance(protected, dict): protected = json_encode(protected) else: json_decode(protected) # check header encoding self.objects['protected'] = protected if unprotected: if isinstance(unprotected, dict): unprotected = json_encode(unprotected) else: json_decode(unprotected) # check header encoding self.objects['unprotected'] = unprotected if algs: self._allowed_algs = algs if recipient: self.add_recipient(recipient, header=header) elif header: raise ValueError('Header is allowed only with default recipient') def _jwa_keymgmt(self, name): allowed = self._allowed_algs or default_allowed_algs if name not in allowed: raise InvalidJWEOperation('Algorithm not allowed') return JWA.keymgmt_alg(name) def _jwa_enc(self, name): allowed = self._allowed_algs or default_allowed_algs if name not in allowed: raise InvalidJWEOperation('Algorithm not allowed') return JWA.encryption_alg(name) @property def allowed_algs(self): """Allowed algorithms. The list of allowed algorithms. Can be changed by setting a list of algorithm names. """ if self._allowed_algs: return self._allowed_algs else: return default_allowed_algs @allowed_algs.setter def allowed_algs(self, algs): if not isinstance(algs, list): raise TypeError('Allowed Algs must be a list') self._allowed_algs = algs def _merge_headers(self, h1, h2): for k in list(h1.keys()): if k in h2: raise InvalidJWEData('Duplicate header: "%s"' % k) h1.update(h2) return h1 def _get_jose_header(self, header=None): jh = dict() if 'protected' in self.objects: ph = json_decode(self.objects['protected']) jh = self._merge_headers(jh, ph) if 'unprotected' in self.objects: uh = json_decode(self.objects['unprotected']) jh = self._merge_headers(jh, uh) if header: rh = json_decode(header) jh = self._merge_headers(jh, rh) return jh def _get_alg_enc_from_headers(self, jh): algname = jh.get('alg', None) if algname is None: raise InvalidJWEData('Missing "alg" from headers') alg = self._jwa_keymgmt(algname) encname = jh.get('enc', None) if encname is None: raise InvalidJWEData('Missing "enc" from headers') enc = self._jwa_enc(encname) return alg, enc def _encrypt(self, alg, enc, jh): aad = base64url_encode(self.objects.get('protected', '')) if 'aad' in self.objects: aad += '.' + base64url_encode(self.objects['aad']) aad = aad.encode('utf-8') compress = jh.get('zip', None) if compress == 'DEF': data = zlib.compress(self.plaintext)[2:-4] elif compress is None: data = self.plaintext else: raise ValueError('Unknown compression') iv, ciphertext, tag = enc.encrypt(self.cek, aad, data) self.objects['iv'] = iv self.objects['ciphertext'] = ciphertext self.objects['tag'] = tag def add_recipient(self, key, header=None): """Encrypt the plaintext with the given key. :param key: A JWK key or password of appropriate type for the 'alg' provided in the JOSE Headers. :param header: A JSON string representing the per-recipient header. :raises ValueError: if the plaintext is missing or not of type bytes. :raises ValueError: if the compression type is unknown. :raises InvalidJWAAlgorithm: if the 'alg' provided in the JOSE headers is missing or unknown, or otherwise not implemented. """ if self.plaintext is None: raise ValueError('Missing plaintext') if not isinstance(self.plaintext, bytes): raise ValueError("Plaintext must be 'bytes'") if isinstance(header, dict): header = json_encode(header) jh = self._get_jose_header(header) alg, enc = self._get_alg_enc_from_headers(jh) rec = dict() if header: rec['header'] = header wrapped = alg.wrap(key, enc.wrap_key_size, self.cek, jh) self.cek = wrapped['cek'] if 'ek' in wrapped: rec['encrypted_key'] = wrapped['ek'] if 'header' in wrapped: h = json_decode(rec.get('header', '{}')) nh = self._merge_headers(h, wrapped['header']) rec['header'] = json_encode(nh) if 'ciphertext' not in self.objects: self._encrypt(alg, enc, jh) if 'recipients' in self.objects: self.objects['recipients'].append(rec) elif 'encrypted_key' in self.objects or 'header' in self.objects: self.objects['recipients'] = list() n = dict() if 'encrypted_key' in self.objects: n['encrypted_key'] = self.objects.pop('encrypted_key') if 'header' in self.objects: n['header'] = self.objects.pop('header') self.objects['recipients'].append(n) self.objects['recipients'].append(rec) else: self.objects.update(rec) def serialize(self, compact=False): """Serializes the object into a JWE token. :param compact(boolean): if True generates the compact representation, otherwise generates a standard JSON format. :raises InvalidJWEOperation: if the object cannot serialized with the compact representation and `compact` is True. :raises InvalidJWEOperation: if no recipients have been added to the object. """ if 'ciphertext' not in self.objects: raise InvalidJWEOperation("No available ciphertext") if compact: for invalid in 'aad', 'unprotected': if invalid in self.objects: raise InvalidJWEOperation( "Can't use compact encoding when the '%s' parameter" "is set" % invalid) if 'protected' not in self.objects: raise InvalidJWEOperation( "Can't use compat encoding without protected headers") else: ph = json_decode(self.objects['protected']) for required in 'alg', 'enc': if required not in ph: raise InvalidJWEOperation( "Can't use compat encoding, '%s' must be in the " "protected header" % required) if 'recipients' in self.objects: if len(self.objects['recipients']) != 1: raise InvalidJWEOperation("Invalid number of recipients") rec = self.objects['recipients'][0] else: rec = self.objects if 'header' in rec: # The AESGCMKW algorithm generates data (iv, tag) we put in the # per-recipient unpotected header by default. Move it to the # protected header and re-encrypt the payload, as the protected # header is used as additional authenticated data. h = json_decode(rec['header']) ph = json_decode(self.objects['protected']) nph = self._merge_headers(h, ph) self.objects['protected'] = json_encode(nph) jh = self._get_jose_header() alg, enc = self._get_alg_enc_from_headers(jh) self._encrypt(alg, enc, jh) del rec['header'] return '.'.join([ base64url_encode(self.objects['protected']), base64url_encode(rec.get('encrypted_key', '')), base64url_encode(self.objects['iv']), base64url_encode(self.objects['ciphertext']), base64url_encode(self.objects['tag']) ]) else: obj = self.objects enc = { 'ciphertext': base64url_encode(obj['ciphertext']), 'iv': base64url_encode(obj['iv']), 'tag': base64url_encode(self.objects['tag']) } if 'protected' in obj: enc['protected'] = base64url_encode(obj['protected']) if 'unprotected' in obj: enc['unprotected'] = json_decode(obj['unprotected']) if 'aad' in obj: enc['aad'] = base64url_encode(obj['aad']) if 'recipients' in obj: enc['recipients'] = list() for rec in obj['recipients']: e = dict() if 'encrypted_key' in rec: e['encrypted_key'] = \ base64url_encode(rec['encrypted_key']) if 'header' in rec: e['header'] = json_decode(rec['header']) enc['recipients'].append(e) else: if 'encrypted_key' in obj: enc['encrypted_key'] = \ base64url_encode(obj['encrypted_key']) if 'header' in obj: enc['header'] = json_decode(obj['header']) return json_encode(enc) def _check_crit(self, crit): for k in crit: if k not in self.header_registry: raise InvalidJWEData('Unknown critical header: "%s"' % k) else: if not self.header_registry[k].supported: raise InvalidJWEData('Unsupported critical header: ' '"%s"' % k) # FIXME: allow to specify which algorithms to accept as valid def _decrypt(self, key, ppe): jh = self._get_jose_header(ppe.get('header', None)) # TODO: allow caller to specify list of headers it understands self._check_crit(jh.get('crit', dict())) for hdr in jh: if hdr in self.header_registry: if not self.header_registry.check_header(hdr, self): raise InvalidJWEData('Failed header check') alg = self._jwa_keymgmt(jh.get('alg', None)) enc = self._jwa_enc(jh.get('enc', None)) aad = base64url_encode(self.objects.get('protected', '')) if 'aad' in self.objects: aad += '.' + base64url_encode(self.objects['aad']) cek = alg.unwrap(key, enc.wrap_key_size, ppe.get('encrypted_key', b''), jh) data = enc.decrypt(cek, aad.encode('utf-8'), self.objects['iv'], self.objects['ciphertext'], self.objects['tag']) self.decryptlog.append('Success') self.cek = cek compress = jh.get('zip', None) if compress == 'DEF': self.plaintext = zlib.decompress(data, -zlib.MAX_WBITS) elif compress is None: self.plaintext = data else: raise ValueError('Unknown compression') def decrypt(self, key): """Decrypt a JWE token. :param key: The (:class:`jwcrypto.jwk.JWK`) decryption key. :param key: A (:class:`jwcrypto.jwk.JWK`) decryption key or a password string (optional). :raises InvalidJWEOperation: if the key is not a JWK object. :raises InvalidJWEData: if the ciphertext can't be decrypted or the object is otherwise malformed. """ if 'ciphertext' not in self.objects: raise InvalidJWEOperation("No available ciphertext") self.decryptlog = list() if 'recipients' in self.objects: for rec in self.objects['recipients']: try: self._decrypt(key, rec) except Exception as e: # pylint: disable=broad-except self.decryptlog.append('Failed: [%s]' % repr(e)) else: try: self._decrypt(key, self.objects) except Exception as e: # pylint: disable=broad-except self.decryptlog.append('Failed: [%s]' % repr(e)) if not self.plaintext: raise InvalidJWEData('No recipient matched the provided ' 'key' + repr(self.decryptlog)) def deserialize(self, raw_jwe, key=None): """Deserialize a JWE token. NOTE: Destroys any current status and tries to import the raw JWE provided. :param raw_jwe: a 'raw' JWE token (JSON Encoded or Compact notation) string. :param key: A (:class:`jwcrypto.jwk.JWK`) decryption key or a password string (optional). If a key is provided a decryption step will be attempted after the object is successfully deserialized. :raises InvalidJWEData: if the raw object is an invaid JWE token. :raises InvalidJWEOperation: if the decryption fails. """ self.objects = dict() self.plaintext = None self.cek = None o = dict() try: try: djwe = json_decode(raw_jwe) o['iv'] = base64url_decode(djwe['iv']) o['ciphertext'] = base64url_decode(djwe['ciphertext']) o['tag'] = base64url_decode(djwe['tag']) if 'protected' in djwe: p = base64url_decode(djwe['protected']) o['protected'] = p.decode('utf-8') if 'unprotected' in djwe: o['unprotected'] = json_encode(djwe['unprotected']) if 'aad' in djwe: o['aad'] = base64url_decode(djwe['aad']) if 'recipients' in djwe: o['recipients'] = list() for rec in djwe['recipients']: e = dict() if 'encrypted_key' in rec: e['encrypted_key'] = \ base64url_decode(rec['encrypted_key']) if 'header' in rec: e['header'] = json_encode(rec['header']) o['recipients'].append(e) else: if 'encrypted_key' in djwe: o['encrypted_key'] = \ base64url_decode(djwe['encrypted_key']) if 'header' in djwe: o['header'] = json_encode(djwe['header']) except ValueError: c = raw_jwe.split('.') if len(c) != 5: raise InvalidJWEData() p = base64url_decode(c[0]) o['protected'] = p.decode('utf-8') ekey = base64url_decode(c[1]) if ekey != b'': o['encrypted_key'] = base64url_decode(c[1]) o['iv'] = base64url_decode(c[2]) o['ciphertext'] = base64url_decode(c[3]) o['tag'] = base64url_decode(c[4]) self.objects = o except Exception as e: # pylint: disable=broad-except raise InvalidJWEData('Invalid format', repr(e)) if key: self.decrypt(key) @property def payload(self): if not self.plaintext: raise InvalidJWEOperation("Plaintext not available") return self.plaintext @property def jose_header(self): jh = self._get_jose_header(self.objects.get('header')) if len(jh) == 0: raise InvalidJWEOperation("JOSE Header not available") return jh
class JWS(object): """JSON Web Signature object This object represent a JWS token. """ def __init__(self, payload=None, header_registry=None): """Creates a JWS object. :param payload(bytes): An arbitrary value (optional). :param header_registry: Optional additions to the header registry """ self.objects = dict() if payload: self.objects['payload'] = payload self.verifylog = None self._allowed_algs = None self.header_registry = JWSEHeaderRegistry(JWSHeaderRegistry) if header_registry: self.header_registry.update(header_registry) @property def allowed_algs(self): """Allowed algorithms. The list of allowed algorithms. Can be changed by setting a list of algorithm names. """ if self._allowed_algs: return self._allowed_algs else: return default_allowed_algs @allowed_algs.setter def allowed_algs(self, algs): if not isinstance(algs, list): raise TypeError('Allowed Algs must be a list') self._allowed_algs = algs @property def is_valid(self): return self.objects.get('valid', False) # TODO: allow caller to specify list of headers it understands # FIXME: Merge and check to be changed to two separate functions def _merge_check_headers(self, protected, *headers): header = None crit = [] if protected is not None: if 'crit' in protected: crit = protected['crit'] # Check immediately if we support these critical headers for k in crit: if k not in self.header_registry: raise InvalidJWSObject( 'Unknown critical header: "%s"' % k) else: if not self.header_registry[k].supported: raise InvalidJWSObject( 'Unsupported critical header: "%s"' % k) header = protected if 'b64' in header: if not isinstance(header['b64'], bool): raise InvalidJWSObject('b64 header must be a boolean') for hn in headers: if hn is None: continue if header is None: header = dict() for h in list(hn.keys()): if h in self.header_registry: if self.header_registry[h].mustprotect: raise InvalidJWSObject('"%s" must be protected' % h) if h in header: raise InvalidJWSObject('Duplicate header: "%s"' % h) header.update(hn) for k in crit: if k not in header: raise InvalidJWSObject('Missing critical header "%s"' % k) return header # TODO: support selecting key with 'kid' and passing in multiple keys def _verify(self, alg, key, payload, signature, protected, header=None): p = dict() # verify it is a valid JSON object and decode if protected is not None: p = json_decode(protected) if not isinstance(p, dict): raise InvalidJWSSignature('Invalid Protected header') # merge heders, and verify there are no duplicates if header: if not isinstance(header, dict): raise InvalidJWSSignature('Invalid Unprotected header') # Merge and check (critical) headers chk_hdrs = self._merge_check_headers(p, header) for hdr in chk_hdrs: if hdr in self.header_registry: if not self.header_registry.check_header(hdr, self): raise InvalidJWSSignature('Failed header check') # check 'alg' is present if alg is None and 'alg' not in p: raise InvalidJWSSignature('No "alg" in headers') if alg: if 'alg' in p and alg != p['alg']: raise InvalidJWSSignature('"alg" mismatch, requested ' '"%s", found "%s"' % (alg, p['alg'])) a = alg else: a = p['alg'] # the following will verify the "alg" is supported and the signature # verifies c = JWSCore(a, key, protected, payload, self._allowed_algs) c.verify(signature) def verify(self, key, alg=None): """Verifies a JWS token. :param key: The (:class:`jwcrypto.jwk.JWK`) verification key. :param alg: The signing algorithm (optional). usually the algorithm is known as it is provided with the JOSE Headers of the token. :raises InvalidJWSSignature: if the verification fails. """ self.verifylog = list() self.objects['valid'] = False obj = self.objects if 'signature' in obj: try: self._verify(alg, key, obj['payload'], obj['signature'], obj.get('protected', None), obj.get('header', None)) obj['valid'] = True except Exception as e: # pylint: disable=broad-except self.verifylog.append('Failed: [%s]' % repr(e)) elif 'signatures' in obj: for o in obj['signatures']: try: self._verify(alg, key, obj['payload'], o['signature'], o.get('protected', None), o.get('header', None)) # Ok if at least one verifies obj['valid'] = True except Exception as e: # pylint: disable=broad-except self.verifylog.append('Failed: [%s]' % repr(e)) else: raise InvalidJWSSignature('No signatures availble') if not self.is_valid: raise InvalidJWSSignature('Verification failed for all ' 'signatures' + repr(self.verifylog)) def _deserialize_signature(self, s): o = dict() o['signature'] = base64url_decode(str(s['signature'])) if 'protected' in s: p = base64url_decode(str(s['protected'])) o['protected'] = p.decode('utf-8') if 'header' in s: o['header'] = s['header'] return o def _deserialize_b64(self, o, protected): if protected is None: b64n = None else: p = json_decode(protected) b64n = p.get('b64') if b64n is not None: if not isinstance(b64n, bool): raise InvalidJWSObject('b64 header must be boolean') b64 = o.get('b64') if b64 == b64n: return elif b64 is None: o['b64'] = b64n else: raise InvalidJWSObject('conflicting b64 values') def deserialize(self, raw_jws, key=None, alg=None): """Deserialize a JWS token. NOTE: Destroys any current status and tries to import the raw JWS provided. :param raw_jws: a 'raw' JWS token (JSON Encoded or Compact notation) string. :param key: A (:class:`jwcrypto.jwk.JWK`) verification key (optional). If a key is provided a verification step will be attempted after the object is successfully deserialized. :param alg: The signing algorithm (optional). usually the algorithm is known as it is provided with the JOSE Headers of the token. :raises InvalidJWSObject: if the raw object is an invaid JWS token. :raises InvalidJWSSignature: if the verification fails. """ self.objects = dict() o = dict() try: try: djws = json_decode(raw_jws) if 'signatures' in djws: o['signatures'] = list() for s in djws['signatures']: os = self._deserialize_signature(s) o['signatures'].append(os) self._deserialize_b64(o, os.get('protected')) else: o = self._deserialize_signature(djws) self._deserialize_b64(o, o.get('protected')) if 'payload' in djws: if o.get('b64', True): o['payload'] = base64url_decode(str(djws['payload'])) else: o['payload'] = djws['payload'] except ValueError: c = raw_jws.split('.') if len(c) != 3: raise InvalidJWSObject('Unrecognized representation') p = base64url_decode(str(c[0])) if len(p) > 0: o['protected'] = p.decode('utf-8') self._deserialize_b64(o, o['protected']) o['payload'] = base64url_decode(str(c[1])) o['signature'] = base64url_decode(str(c[2])) self.objects = o except Exception as e: # pylint: disable=broad-except raise InvalidJWSObject('Invalid format', repr(e)) if key: self.verify(key, alg) def add_signature(self, key, alg=None, protected=None, header=None): """Adds a new signature to the object. :param key: A (:class:`jwcrypto.jwk.JWK`) key of appropriate for the "alg" provided. :param alg: An optional algorithm name. If already provided as an element of the protected or unprotected header it can be safely omitted. :param protected: The Protected Header (optional) :param header: The Unprotected Header (optional) :raises InvalidJWSObject: if no payload has been set on the object, or invalid headers are provided. :raises ValueError: if the key is not a :class:`JWK` object. :raises ValueError: if the algorithm is missing or is not provided by one of the headers. :raises InvalidJWAAlgorithm: if the algorithm is not valid, is unknown or otherwise not yet implemented. """ if not self.objects.get('payload', None): raise InvalidJWSObject('Missing Payload') b64 = True p = dict() if protected: if isinstance(protected, dict): p = protected protected = json_encode(p) else: p = json_decode(protected) # If b64 is present we must enforce criticality if 'b64' in list(p.keys()): crit = p.get('crit', []) if 'b64' not in crit: raise InvalidJWSObject('b64 header must always be critical') b64 = p['b64'] if 'b64' in self.objects: if b64 != self.objects['b64']: raise InvalidJWSObject('Mixed b64 headers on signatures') h = None if header: if isinstance(header, dict): h = header header = json_encode(header) else: h = json_decode(header) p = self._merge_check_headers(p, h) if 'alg' in p: if alg is None: alg = p['alg'] elif alg != p['alg']: raise ValueError('"alg" value mismatch, specified "alg" ' 'does not match JOSE header value') if alg is None: raise ValueError('"alg" not specified') c = JWSCore(alg, key, protected, self.objects['payload'], self.allowed_algs) sig = c.sign() o = dict() o['signature'] = base64url_decode(sig['signature']) if protected: o['protected'] = protected if header: o['header'] = h o['valid'] = True if 'signatures' in self.objects: self.objects['signatures'].append(o) elif 'signature' in self.objects: self.objects['signatures'] = list() n = dict() n['signature'] = self.objects.pop('signature') if 'protected' in self.objects: n['protected'] = self.objects.pop('protected') if 'header' in self.objects: n['header'] = self.objects.pop('header') if 'valid' in self.objects: n['valid'] = self.objects.pop('valid') self.objects['signatures'].append(n) self.objects['signatures'].append(o) else: self.objects.update(o) self.objects['b64'] = b64 def serialize(self, compact=False): """Serializes the object into a JWS token. :param compact(boolean): if True generates the compact representation, otherwise generates a standard JSON format. :raises InvalidJWSOperation: if the object cannot serialized with the compact representation and `compat` is True. :raises InvalidJWSSignature: if no signature has been added to the object, or no valid signature can be found. """ if compact: if 'signatures' in self.objects: raise InvalidJWSOperation("Can't use compact encoding with " "multiple signatures") if 'signature' not in self.objects: raise InvalidJWSSignature("No available signature") if not self.objects.get('valid', False): raise InvalidJWSSignature("No valid signature found") if 'protected' in self.objects: protected = base64url_encode(self.objects['protected']) else: protected = '' if self.objects.get('payload', False): if self.objects.get('b64', True): payload = base64url_encode(self.objects['payload']) else: if isinstance(self.objects['payload'], bytes): payload = self.objects['payload'].decode('utf-8') else: payload = self.objects['payload'] if '.' in payload: raise InvalidJWSOperation( "Can't use compact encoding with unencoded " "payload that uses the . character") else: payload = '' return '.'.join([ protected, payload, base64url_encode(self.objects['signature']) ]) else: obj = self.objects sig = dict() if self.objects.get('payload', False): if self.objects.get('b64', True): sig['payload'] = base64url_encode(self.objects['payload']) else: sig['payload'] = self.objects['payload'] if 'signature' in obj: if not obj.get('valid', False): raise InvalidJWSSignature("No valid signature found") sig['signature'] = base64url_encode(obj['signature']) if 'protected' in obj: sig['protected'] = base64url_encode(obj['protected']) if 'header' in obj: sig['header'] = obj['header'] elif 'signatures' in obj: sig['signatures'] = list() for o in obj['signatures']: if not o.get('valid', False): continue s = {'signature': base64url_encode(o['signature'])} if 'protected' in o: s['protected'] = base64url_encode(o['protected']) if 'header' in o: s['header'] = o['header'] sig['signatures'].append(s) if len(sig['signatures']) == 0: raise InvalidJWSSignature("No valid signature found") else: raise InvalidJWSSignature("No available signature") return json_encode(sig) @property def payload(self): if 'payload' not in self.objects: raise InvalidJWSOperation("Payload not available") if not self.is_valid: raise InvalidJWSOperation("Payload not verified") return self.objects['payload'] def detach_payload(self): self.objects.pop('payload', None) @property def jose_header(self): obj = self.objects if 'signature' in obj: if 'protected' in obj: p = json_decode(obj['protected']) else: p = None return self._merge_check_headers(p, obj.get('header', dict())) elif 'signatures' in self.objects: jhl = list() for o in obj['signatures']: jh = dict() if 'protected' in o: p = json_decode(o['protected']) else: p = None jh = self._merge_check_headers(p, o.get('header', dict())) jhl.append(jh) return jhl else: raise InvalidJWSOperation("JOSE Header(s) not available")