diff options
Diffstat (limited to 'macaroonbakery/oven.py')
-rw-r--r-- | macaroonbakery/oven.py | 254 |
1 files changed, 254 insertions, 0 deletions
diff --git a/macaroonbakery/oven.py b/macaroonbakery/oven.py new file mode 100644 index 0000000..69a89cb --- /dev/null +++ b/macaroonbakery/oven.py @@ -0,0 +1,254 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. + +import base64 +import hashlib +import itertools +import os + +import google +from pymacaroons import MACAROON_V2, Verifier +import six + +import macaroonbakery +import macaroonbakery.checkers as checkers +from macaroonbakery import utils +from macaroonbakery.internal import id_pb2 + + +class Oven: + ''' Oven bakes macaroons. They emerge sweet and delicious and ready for use + in a Checker. + + All macaroons are associated with one or more operations (see + the Op type) which define the capabilities of the macaroon. + + There is one special operation, "login" (defined by LOGIN_OP) which grants + the capability to speak for a particular user. + The login capability will never be mixed with other capabilities. + + It is up to the caller to decide on semantics for other operations. + ''' + + def __init__(self, key=None, location=None, locator=None, namespace=None, + root_keystore_for_ops=None, ops_store=None): + ''' + @param namespace holds the namespace to use when adding first party + caveats. + @param root_keystore_for_ops a function that will give the macaroon + storage to be used for root keys associated with macaroons created + with macaroon. + @param ops_store object is used to persistently store the association + of multi-op entities with their associated operations when macaroon is + called with multiple operations. + When this is in use, operation entities with the prefix "multi-" are + reserved - a "multi-"-prefixed entity represents a set of operations + stored in the OpsStore. + @param key holds the private nacl key pair used to encrypt third party + caveats. If it is None, no third party caveats can be created. + @param location string holds the location that will be associated with + new macaroons (as returned by Macaroon.Location). + @param locator is used to find out information on third parties when + adding third party caveats. If this is None, no non-local third + party caveats can be added. + ''' + self.key = key + self.location = location + self.locator = locator + if namespace is None: + namespace = checkers.Checker().namespace() + self.namespace = namespace + self.ops_store = ops_store + self.root_keystore_for_ops = root_keystore_for_ops + if root_keystore_for_ops is None: + my_store = macaroonbakery.MemoryKeyStore() + self.root_keystore_for_ops = lambda x: my_store + + def macaroon(self, version, expiry, caveats, ops): + ''' Takes a macaroon with the given version from the oven, + associates it with the given operations and attaches the given caveats. + There must be at least one operation specified. + The macaroon will expire at the given time - a time_before first party + caveat will be added with that time. + + @return: a new Macaroon object. + ''' + if len(ops) == 0: + raise ValueError('cannot mint a macaroon associated ' + 'with no operations') + + ops = canonical_ops(ops) + root_key, storage_id = self.root_keystore_for_ops(ops).root_key() + + id = self._new_macaroon_id(storage_id, expiry, ops) + + id_bytes = six.int2byte(macaroonbakery.LATEST_BAKERY_VERSION) + \ + id.SerializeToString() + + if macaroonbakery.macaroon_version(version) < MACAROON_V2: + # The old macaroon format required valid text for the macaroon id, + # so base64-encode it. + id_bytes = utils.raw_urlsafe_b64encode(id_bytes) + + m = macaroonbakery.Macaroon(root_key, id_bytes, self.location, version, + self.namespace) + m.add_caveat(checkers.time_before_caveat(expiry), self.key, + self.locator) + m.add_caveats(caveats, self.key, self.locator) + return m + + def _new_macaroon_id(self, storage_id, expiry, ops): + nonce = os.urandom(16) + if len(ops) == 1 or self.ops_store is None: + return id_pb2.MacaroonId( + nonce=nonce, + storageId=storage_id, + ops=_macaroon_id_ops(ops)) + # We've got several operations and a multi-op store, so use the store. + # TODO use the store only if the encoded macaroon id exceeds some size? + entity = self.ops_entity(ops) + self.ops_store.put_ops(entity, expiry, ops) + return id_pb2.MacaroonId( + nonce=nonce, + storageId=storage_id, + ops=[id_pb2.Op(entity=entity, actions=['*'])]) + + def ops_entity(self, ops): + ''' Returns a new multi-op entity name string that represents + all the given operations and caveats. It returns the same value + regardless of the ordering of the operations. It assumes that the + operations have been canonicalized and that there's at least one + operation. + + :param ops: + :return: string that represents all the given operations and caveats. + ''' + # Hash the operations, removing duplicates as we go. + hash_entity = hashlib.sha256() + for op in ops: + hash_entity.update('{}\n{}\n'.format( + op.action, op.entity).encode()) + hash_encoded = base64.urlsafe_b64encode(hash_entity.digest()) + return 'multi-' + hash_encoded.decode('utf-8').rstrip('=') + + def macaroon_ops(self, macaroons): + ''' This method makes the oven satisfy the MacaroonOpStore protocol + required by the Checker class. + + For macaroons minted with previous bakery versions, it always + returns a single LoginOp operation. + + :param macaroons: + :return: + ''' + if len(macaroons) == 0: + raise ValueError('no macaroons provided') + + storage_id, ops = _decode_macaroon_id(macaroons[0].identifier_bytes) + root_key = self.root_keystore_for_ops(ops).get(storage_id) + if root_key is None: + raise macaroonbakery.VerificationError( + 'macaroon key not found in storage') + v = Verifier() + conditions = [] + + def validator(condition): + # Verify the macaroon's signature only. Don't check any of the + # caveats yet but save them so that we can return them. + conditions.append(condition) + return True + v.satisfy_general(validator) + v.verify(macaroons[0], root_key, macaroons[1:]) + if (self.ops_store is not None + and len(ops) == 1 + and ops[0].entity.startswith('multi-')): + # It's a multi-op entity, so retrieve the actual operations + # it's associated with. + ops = self.ops_store.get_ops(ops[0].entity) + + return ops, conditions + + +def _decode_macaroon_id(id): + storage_id = b'' + base64_decoded = False + first = id[:1] + if first == b'A': + # The first byte is not a version number and it's 'A', which is the + # base64 encoding of the top 6 bits (all zero) of the version number 2 + # or 3, so we assume that it's the base64 encoding of a new-style + # macaroon id, so we base64 decode it. + # + # Note that old-style ids always start with an ASCII character >= 4 + # (> 32 in fact) so this logic won't be triggered for those. + try: + dec = utils.raw_b64decode(id.decode('utf-8')) + # Set the id only on success. + id = dec + base64_decoded = True + except: + # if it's a bad encoding, we'll get an error which is fine + pass + + # Trim any extraneous information from the id before retrieving + # it from storage, including the UUID that's added when + # creating macaroons to make all macaroons unique even if + # they're using the same root key. + first = six.byte2int(id[:1]) + if first == macaroonbakery.BAKERY_V2: + # Skip the UUID at the start of the id. + storage_id = id[1 + 16:] + if first == macaroonbakery.BAKERY_V3: + try: + id1 = id_pb2.MacaroonId.FromString(id[1:]) + except google.protobuf.message.DecodeError: + raise macaroonbakery.VerificationError( + 'no operations found in macaroon') + if len(id1.ops) == 0 or len(id1.ops[0].actions) == 0: + raise macaroonbakery.VerificationError( + 'no operations found in macaroon') + + ops = [] + for op in id1.ops: + for action in op.actions: + ops.append(macaroonbakery.Op(op.entity, action)) + return id1.storageId, ops + + if not base64_decoded and _is_lower_case_hex_char(first): + # It's an old-style id, probably with a hyphenated UUID. + # so trim that off. + last = id.rfind(b'-') + if last >= 0: + storage_id = id[0:last] + return storage_id, [macaroonbakery.LOGIN_OP] + + +def _is_lower_case_hex_char(b): + if ord('0') <= b <= ord('9'): + return True + if ord('a') <= b <= ord('f'): + return True + return False + + +def canonical_ops(ops): + ''' Returns the given operations array sorted with duplicates removed. + + @param ops checker.Ops + @return: checker.Ops + ''' + new_ops = sorted(set(ops), key=lambda x: (x.entity, x.action)) + return new_ops + + +def _macaroon_id_ops(ops): + '''Return operations suitable for serializing as part of a MacaroonId. + + It assumes that ops has been canonicalized and that there's at least + one operation. + ''' + id_ops = [] + for entity, entity_ops in itertools.groupby(ops, lambda x: x.entity): + actions = map(lambda x: x.action, entity_ops) + id_ops.append(id_pb2.Op(entity=entity, actions=actions)) + return id_ops |