summaryrefslogtreecommitdiff
path: root/macaroonbakery/oven.py
diff options
context:
space:
mode:
Diffstat (limited to 'macaroonbakery/oven.py')
-rw-r--r--macaroonbakery/oven.py254
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