summaryrefslogtreecommitdiff
path: root/macaroonbakery/bakery
diff options
context:
space:
mode:
Diffstat (limited to 'macaroonbakery/bakery')
-rw-r--r--macaroonbakery/bakery/__init__.py141
-rw-r--r--macaroonbakery/bakery/_authorizer.py106
-rw-r--r--macaroonbakery/bakery/_bakery.py72
-rw-r--r--macaroonbakery/bakery/_checker.py417
-rw-r--r--macaroonbakery/bakery/_codec.py301
-rw-r--r--macaroonbakery/bakery/_discharge.py241
-rw-r--r--macaroonbakery/bakery/_error.py77
-rw-r--r--macaroonbakery/bakery/_identity.py126
-rw-r--r--macaroonbakery/bakery/_internal/__init__.py0
-rw-r--r--macaroonbakery/bakery/_internal/id.proto14
-rw-r--r--macaroonbakery/bakery/_internal/id_pb2.py132
-rw-r--r--macaroonbakery/bakery/_keys.py100
-rw-r--r--macaroonbakery/bakery/_macaroon.py430
-rw-r--r--macaroonbakery/bakery/_oven.py283
-rw-r--r--macaroonbakery/bakery/_store.py77
-rw-r--r--macaroonbakery/bakery/_third_party.py57
-rw-r--r--macaroonbakery/bakery/_versions.py9
17 files changed, 2583 insertions, 0 deletions
diff --git a/macaroonbakery/bakery/__init__.py b/macaroonbakery/bakery/__init__.py
new file mode 100644
index 0000000..4b973e9
--- /dev/null
+++ b/macaroonbakery/bakery/__init__.py
@@ -0,0 +1,141 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+
+from ._versions import (
+ VERSION_0,
+ VERSION_1,
+ VERSION_2,
+ VERSION_3,
+ LATEST_VERSION,
+)
+from ._authorizer import (
+ ACLAuthorizer,
+ Authorizer,
+ AuthorizerFunc,
+ ClosedAuthorizer,
+ EVERYONE,
+)
+from ._codec import (
+ decode_caveat,
+ encode_caveat,
+ encode_uvarint,
+)
+from ._checker import (
+ AuthChecker,
+ AuthInfo,
+ Checker,
+ LOGIN_OP,
+ Op,
+)
+from ._error import (
+ AuthInitError,
+ CaveatNotRecognizedError,
+ DischargeRequiredError,
+ IdentityError,
+ PermissionDenied,
+ ThirdPartyCaveatCheckFailed,
+ ThirdPartyInfoNotFound,
+ VerificationError,
+)
+from ._identity import (
+ ACLIdentity,
+ Identity,
+ IdentityClient,
+ NoIdentities,
+ SimpleIdentity,
+)
+from ._keys import (
+ generate_key,
+ PrivateKey,
+ PublicKey,
+)
+from ._store import (
+ MemoryOpsStore,
+ MemoryKeyStore,
+)
+from ._third_party import (
+ ThirdPartyCaveatInfo,
+ ThirdPartyInfo,
+ legacy_namespace,
+)
+from ._macaroon import (
+ Macaroon,
+ MacaroonJSONDecoder,
+ MacaroonJSONEncoder,
+ ThirdPartyLocator,
+ ThirdPartyStore,
+ macaroon_version,
+)
+from ._discharge import (
+ ThirdPartyCaveatChecker,
+ discharge,
+ discharge_all,
+ local_third_party_caveat,
+)
+from ._oven import (
+ Oven,
+ canonical_ops,
+)
+from ._bakery import Bakery
+from macaroonbakery._utils import (
+ b64decode,
+ macaroon_to_dict,
+)
+
+__all__ = [
+ 'ACLAuthorizer',
+ 'ACLIdentity',
+ 'AuthChecker',
+ 'AuthInfo',
+ 'AuthInitError',
+ 'Authorizer',
+ 'AuthorizerFunc',
+ 'VERSION_0',
+ 'VERSION_1',
+ 'VERSION_2',
+ 'VERSION_3',
+ 'Bakery',
+ 'CaveatNotRecognizedError',
+ 'Checker',
+ 'ClosedAuthorizer',
+ 'DischargeRequiredError',
+ 'EVERYONE',
+ 'Identity',
+ 'IdentityClient',
+ 'IdentityError',
+ 'LATEST_VERSION',
+ 'LOGIN_OP',
+ 'Macaroon',
+ 'MacaroonJSONDecoder',
+ 'MacaroonJSONEncoder',
+ 'MemoryKeyStore',
+ 'MemoryOpsStore',
+ 'NoIdentities',
+ 'Op',
+ 'Oven',
+ 'PermissionDenied',
+ 'PrivateKey',
+ 'PublicKey',
+ 'SimpleIdentity',
+ 'ThirdPartyCaveatCheckFailed',
+ 'ThirdPartyCaveatChecker',
+ 'ThirdPartyCaveatInfo',
+ 'ThirdPartyInfo',
+ 'ThirdPartyInfoNotFound',
+ 'ThirdPartyLocator',
+ 'ThirdPartyStore',
+ 'VERSION',
+ 'VerificationError',
+ 'b64decode',
+ 'canonical_ops',
+ 'decode_caveat',
+ 'discharge',
+ 'discharge_all',
+ 'encode_caveat',
+ 'encode_uvarint',
+ 'generate_key',
+ 'legacy_namespace',
+ 'local_third_party_caveat',
+ 'macaroon_to_dict',
+ 'macaroon_version',
+]
diff --git a/macaroonbakery/bakery/_authorizer.py b/macaroonbakery/bakery/_authorizer.py
new file mode 100644
index 0000000..f900430
--- /dev/null
+++ b/macaroonbakery/bakery/_authorizer.py
@@ -0,0 +1,106 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+import abc
+
+from ._identity import ACLIdentity
+
+# EVERYONE is recognized by ACLAuthorizer as the name of a
+# group that has everyone in it.
+EVERYONE = 'everyone'
+
+
+class Authorizer(object):
+ ''' Used to check whether a given user is allowed to perform a set of
+ operations.
+ '''
+ __metaclass__ = abc.ABCMeta
+
+ @abc.abstractmethod
+ def authorize(self, ctx, id, ops):
+ ''' Checks whether the given identity (which will be None when there is
+ no authenticated user) is allowed to perform the given operations.
+ It should raise an exception only when the authorization cannot be
+ determined, not when the user has been denied access.
+
+ On success, each element of allowed holds whether the respective
+ element of ops has been allowed, and caveats holds any additional
+ third party caveats that apply.
+ If allowed is shorter then ops, the additional elements are assumed to
+ be False.
+ ctx(AuthContext) is the context of the authorization request.
+ :return: a list of boolean and a list of caveats
+ '''
+ raise NotImplementedError('authorize method must be defined in '
+ 'subclass')
+
+
+class AuthorizerFunc(Authorizer):
+ ''' Implements a simplified version of Authorizer that operates on a single
+ operation at a time.
+ '''
+ def __init__(self, f):
+ '''
+ :param f: a function that takes an identity that operates on a single
+ operation at a time. Will return if this op is allowed as a boolean and
+ and a list of caveat that holds any additional third party caveats
+ that apply.
+ '''
+ self._f = f
+
+ def authorize(self, ctx, identity, ops):
+ '''Implements Authorizer.authorize by calling f with the given identity
+ for each operation.
+ '''
+ allowed = []
+ caveats = []
+ for op in ops:
+ ok, fcaveats = self._f(ctx, identity, op)
+ allowed.append(ok)
+ if fcaveats is not None:
+ caveats.extend(fcaveats)
+ return allowed, caveats
+
+
+class ACLAuthorizer(Authorizer):
+ ''' ACLAuthorizer is an Authorizer implementation that will check access
+ control list (ACL) membership of users. It uses get_acl to find out
+ the ACLs that apply to the requested operations and will authorize an
+ operation if an ACL contains the group "everyone" or if the identity is
+ an instance of ACLIdentity and its allow method returns True for the ACL.
+ '''
+ def __init__(self, get_acl, allow_public=False):
+ '''
+ :param get_acl get_acl will be called with an auth context and an Op.
+ It should return the ACL that applies (an array of string ids).
+ If an entity cannot be found or the action is not recognised,
+ get_acl should return an empty list but no error.
+ :param allow_public: boolean, If True and an ACL contains "everyone",
+ then authorization will be granted even if there is no logged in user.
+ '''
+ self._allow_public = allow_public
+ self._get_acl = get_acl
+
+ def authorize(self, ctx, identity, ops):
+ '''Implements Authorizer.authorize by calling identity.allow to
+ determine whether the identity is a member of the ACLs associated with
+ the given operations.
+ '''
+ if len(ops) == 0:
+ # Anyone is allowed to do nothing.
+ return [], []
+ allowed = [False] * len(ops)
+ has_allow = isinstance(identity, ACLIdentity)
+ for i, op in enumerate(ops):
+ acl = self._get_acl(ctx, op)
+ if has_allow:
+ allowed[i] = identity.allow(ctx, acl)
+ else:
+ allowed[i] = self._allow_public and EVERYONE in acl
+ return allowed, []
+
+
+class ClosedAuthorizer(Authorizer):
+ ''' An Authorizer implementation that will never authorize anything.
+ '''
+ def authorize(self, ctx, id, ops):
+ return [False] * len(ops), []
diff --git a/macaroonbakery/bakery/_bakery.py b/macaroonbakery/bakery/_bakery.py
new file mode 100644
index 0000000..8fac9ce
--- /dev/null
+++ b/macaroonbakery/bakery/_bakery.py
@@ -0,0 +1,72 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+
+from ._authorizer import ClosedAuthorizer
+from ._checker import Checker
+import macaroonbakery.checkers as checkers
+from ._oven import Oven
+
+
+class Bakery(object):
+ '''Convenience class that contains both an Oven and a Checker.
+ '''
+ def __init__(self, location=None, locator=None, ops_store=None, key=None,
+ identity_client=None, checker=None, root_key_store=None,
+ authorizer=ClosedAuthorizer()):
+ '''Returns a new Bakery instance which combines an Oven with a
+ Checker for the convenience of callers that wish to use both
+ together.
+ @param checker holds the checker used to check first party caveats.
+ If this is None, it will use checkers.Checker(None).
+ @param root_key_store holds the root key store to use.
+ If you need to use a different root key store for different operations,
+ you'll need to pass a root_key_store_for_ops value to Oven directly.
+ @param root_key_store If this is None, it will use MemoryKeyStore().
+ Note that that is almost certain insufficient for production services
+ that are spread across multiple instances or that need
+ to persist keys across restarts.
+ @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.
+ @param key holds the private key of the oven. If this is None,
+ no third party caveats may be added.
+ @param identity_client holds the identity implementation to use for
+ authentication. If this is None, no authentication will be possible.
+ @param authorizer is used to check whether an authenticated user is
+ allowed to perform operations. If it is None, it will use
+ a ClosedAuthorizer.
+ The identity parameter passed to authorizer.allow will
+ always have been obtained from a call to
+ IdentityClient.declared_identity.
+ @param ops_store used to persistently store the association of
+ multi-op entities with their associated operations
+ when oven.macaroon is called with multiple operations.
+ @param location holds the location to use when creating new macaroons.
+ '''
+
+ if checker is None:
+ checker = checkers.Checker()
+ root_keystore_for_ops = None
+ if root_key_store is not None:
+ def root_keystore_for_ops(ops):
+ return root_key_store
+
+ oven = Oven(key=key,
+ location=location,
+ locator=locator,
+ namespace=checker.namespace(),
+ root_keystore_for_ops=root_keystore_for_ops,
+ ops_store=ops_store)
+ self._oven = oven
+
+ self._checker = Checker(checker=checker, authorizer=authorizer,
+ identity_client=identity_client,
+ macaroon_opstore=oven)
+
+ @property
+ def oven(self):
+ return self._oven
+
+ @property
+ def checker(self):
+ return self._checker
diff --git a/macaroonbakery/bakery/_checker.py b/macaroonbakery/bakery/_checker.py
new file mode 100644
index 0000000..b796502
--- /dev/null
+++ b/macaroonbakery/bakery/_checker.py
@@ -0,0 +1,417 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+from collections import namedtuple
+from threading import Lock
+
+from ._authorizer import ClosedAuthorizer
+from ._identity import NoIdentities
+from ._error import (
+ AuthInitError,
+ VerificationError,
+ IdentityError,
+ DischargeRequiredError,
+ PermissionDenied,
+)
+import macaroonbakery.checkers as checkers
+import pyrfc3339
+
+
+class Op(namedtuple('Op', 'entity, action')):
+ ''' Op holds an entity and action to be authorized on that entity.
+ entity string holds the name of the entity to be authorized.
+
+ @param entity should not contain spaces and should
+ not start with the prefix "login" or "multi-" (conventionally,
+ entity names will be prefixed with the entity type followed
+ by a hyphen.
+ @param action string holds the action to perform on the entity,
+ such as "read" or "delete". It is up to the service using a checker
+ to define a set of operations and keep them consistent over time.
+ '''
+
+
+# LOGIN_OP represents a login (authentication) operation.
+# A macaroon that is associated with this operation generally
+# carries authentication information with it.
+LOGIN_OP = Op(entity='login', action='login')
+
+
+class Checker(object):
+ '''Checker implements an authentication and authorization checker.
+
+ It uses macaroons as authorization tokens but it is not itself responsible
+ for creating the macaroons
+ See the Oven type (TODO) for one way of doing that.
+ '''
+ def __init__(self, checker=checkers.Checker(),
+ authorizer=ClosedAuthorizer(),
+ identity_client=None,
+ macaroon_opstore=None):
+ '''
+ :param checker: a first party checker implementing a
+ :param authorizer (Authorizer): used to check whether an authenticated
+ user is allowed to perform operations.
+ The identity parameter passed to authorizer.allow will always have been
+ obtained from a call to identity_client.declared_identity.
+ :param identity_client (IdentityClient) used for interactions with the
+ external identity service used for authentication.
+ If this is None, no authentication will be possible.
+ :param macaroon_opstore (object with new_macaroon and macaroon_ops
+ method): used to retrieve macaroon root keys and other associated
+ information.
+ '''
+ self._first_party_caveat_checker = checker
+ self._authorizer = authorizer
+ if identity_client is None:
+ identity_client = NoIdentities()
+ self._identity_client = identity_client
+ self._macaroon_opstore = macaroon_opstore
+
+ def auth(self, mss):
+ ''' Returns a new AuthChecker instance using the given macaroons to
+ inform authorization decisions.
+ @param mss: a list of macaroon lists.
+ '''
+ return AuthChecker(parent=self,
+ macaroons=mss)
+
+ def namespace(self):
+ ''' Returns the namespace of the first party checker.
+ '''
+ return self._first_party_caveat_checker.namespace()
+
+
+class AuthChecker(object):
+ '''Authorizes operations with respect to a user's request.
+
+ The identity is authenticated only once, the first time any method
+ of the AuthChecker is called, using the context passed in then.
+
+ To find out any declared identity without requiring a login,
+ use allow(ctx); to require authentication but no additional operations,
+ use allow(ctx, LOGIN_OP).
+ '''
+ def __init__(self, parent, macaroons):
+ '''
+
+ :param parent (Checker): used to check first party caveats.
+ :param macaroons: a list of py macaroons
+ '''
+ self._macaroons = macaroons
+ self._init_errors = []
+ self._executed = False
+ self._identity = None
+ self._identity_caveats = []
+ self.parent = parent
+ self._conditions = None
+ self._mutex = Lock()
+
+ def _init(self, ctx):
+ with self._mutex:
+ if not self._executed:
+ self._init_once(ctx)
+ self._executed = True
+ if self._init_errors:
+ raise AuthInitError(self._init_errors[0])
+
+ def _init_once(self, ctx):
+ self._auth_indexes = {}
+ self._conditions = [None] * len(self._macaroons)
+ for i, ms in enumerate(self._macaroons):
+ try:
+ ops, conditions = self.parent._macaroon_opstore.macaroon_ops(
+ ms)
+ except VerificationError:
+ raise
+ except Exception as exc:
+ self._init_errors.append(exc.args[0])
+ continue
+
+ # It's a valid macaroon (in principle - we haven't checked first
+ # party caveats).
+ self._conditions[i] = conditions
+ is_login = False
+ for op in ops:
+ if op == LOGIN_OP:
+ # Don't associate the macaroon with the login operation
+ # until we've verified that it is valid below
+ is_login = True
+ else:
+ if op not in self._auth_indexes:
+ self._auth_indexes[op] = []
+ self._auth_indexes[op].append(i)
+ if not is_login:
+ continue
+ # It's a login macaroon. Check the conditions now -
+ # all calls want to see the same authentication
+ # information so that callers have a consistent idea of
+ # the client's identity.
+ #
+ # If the conditions fail, we won't use the macaroon for
+ # identity, but we can still potentially use it for its
+ # other operations if the conditions succeed for those.
+ declared, err = self._check_conditions(ctx, LOGIN_OP, conditions)
+ if err is not None:
+ self._init_errors.append('cannot authorize login macaroon: ' +
+ err)
+ continue
+ if self._identity is not None:
+ # We've already found a login macaroon so ignore this one
+ # for the purposes of identity.
+ continue
+
+ try:
+ identity = self.parent._identity_client.declared_identity(
+ ctx, declared)
+ except IdentityError as exc:
+ self._init_errors.append(
+ 'cannot decode declared identity: {}'.format(exc.args[0]))
+ continue
+ if LOGIN_OP not in self._auth_indexes:
+ self._auth_indexes[LOGIN_OP] = []
+ self._auth_indexes[LOGIN_OP].append(i)
+ self._identity = identity
+
+ if self._identity is None:
+ # No identity yet, so try to get one based on the context.
+ try:
+ identity, cavs = self.parent.\
+ _identity_client.identity_from_context(ctx)
+ except IdentityError:
+ self._init_errors.append('could not determine identity')
+ if cavs is None:
+ cavs = []
+ self._identity, self._identity_caveats = identity, cavs
+ return None
+
+ def allow(self, ctx, ops):
+ ''' Checks that the authorizer's request is authorized to
+ perform all the given operations. Note that allow does not check
+ first party caveats - if there is more than one macaroon that may
+ authorize the request, it will choose the first one that does
+ regardless.
+
+ If all the operations are allowed, an AuthInfo is returned holding
+ details of the decision and any first party caveats that must be
+ checked before actually executing any operation.
+
+ If operations include LOGIN_OP, the request should contain an
+ authentication macaroon proving the client's identity. Once an
+ authentication macaroon is chosen, it will be used for all other
+ authorization requests.
+
+ If an operation was not allowed, an exception will be raised which may
+ be DischargeRequiredError holding the operations that remain to
+ be authorized in order to allow authorization to proceed.
+ @param ctx AuthContext
+ @param ops an array of Op
+ :return: an AuthInfo object.
+ '''
+ auth_info, _ = self.allow_any(ctx, ops)
+ return auth_info
+
+ def allow_any(self, ctx, ops):
+ ''' like allow except that it will authorize as many of the
+ operations as possible without requiring any to be authorized. If all
+ the operations succeeded, the array will be nil.
+
+ If any the operations failed, the returned error will be the same
+ that allow would return and each element in the returned slice will
+ hold whether its respective operation was allowed.
+
+ If all the operations succeeded, the returned slice will be None.
+
+ The returned AuthInfo will always be non-None.
+
+ The LOGIN_OP operation is treated specially - it is always required if
+ present in ops.
+ @param ctx AuthContext
+ @param ops an array of Op
+ :return: an AuthInfo object and the auth used as an array of int.
+ '''
+ authed, used = self._allow_any(ctx, ops)
+ return self._new_auth_info(used), authed
+
+ def _new_auth_info(self, used):
+ info = AuthInfo(identity=self._identity, macaroons=[])
+ for i, is_used in enumerate(used):
+ if is_used:
+ info.macaroons.append(self._macaroons[i])
+ return info
+
+ def _allow_any(self, ctx, ops):
+ self._init(ctx)
+ used = [False] * len(self._macaroons)
+ authed = [False] * len(ops)
+ num_authed = 0
+ errors = []
+ for i, op in enumerate(ops):
+ for mindex in self._auth_indexes.get(op, []):
+ _, err = self._check_conditions(ctx, op,
+ self._conditions[mindex])
+ if err is not None:
+ errors.append(err)
+ continue
+ authed[i] = True
+ num_authed += 1
+ used[mindex] = True
+ # Use the first authorized macaroon only.
+ break
+ if op == LOGIN_OP and not authed[i] and self._identity is not None:
+ # Allow LOGIN_OP when there's an authenticated user even
+ # when there's no macaroon that specifically authorizes it.
+ authed[i] = True
+ if self._identity is not None:
+ # We've authenticated as a user, so even if the operations didn't
+ # specifically require it, we add the login macaroon
+ # to the macaroons used.
+ # Note that the LOGIN_OP conditions have already been checked
+ # successfully in initOnceFunc so no need to check again.
+ # Note also that there may not be any macaroons if the
+ # identity client decided on an identity even with no
+ # macaroons.
+ for i in self._auth_indexes.get(LOGIN_OP, []):
+ used[i] = True
+ if num_authed == len(ops):
+ # All operations allowed.
+ return authed, used
+ # There are some unauthorized operations.
+ need = []
+ need_index = [0] * (len(ops) - num_authed)
+ for i, ok in enumerate(authed):
+ if not ok:
+ need_index[len(need)] = i
+ need.append(ops[i])
+
+ # Try to authorize the operations
+ # even if we haven't got an authenticated user.
+ oks, caveats = self.parent._authorizer.authorize(
+ ctx, self._identity, need)
+ still_need = []
+ for i, _ in enumerate(need):
+ if i < len(oks) and oks[i]:
+ authed[need_index[i]] = True
+ else:
+ still_need.append(ops[need_index[i]])
+ if len(still_need) == 0 and len(caveats) == 0:
+ # No more ops need to be authenticated and
+ # no caveats to be discharged.
+ return authed, used
+ if self._identity is None and len(self._identity_caveats) > 0:
+ raise DischargeRequiredError(
+ msg='authentication required',
+ ops=[LOGIN_OP],
+ cavs=self._identity_caveats)
+ if caveats is None or len(caveats) == 0:
+ all_errors = []
+ all_errors.extend(self._init_errors)
+ all_errors.extend(errors)
+ err = ''
+ if len(all_errors) > 0:
+ err = all_errors[0]
+ raise PermissionDenied(err)
+ raise DischargeRequiredError(
+ msg='some operations have extra caveats', ops=ops, cavs=caveats)
+
+ def allow_capability(self, ctx, ops):
+ '''Checks that the user is allowed to perform all the
+ given operations. If not, a discharge error will be raised.
+ If allow_capability succeeds, it returns a list of first party caveat
+ conditions that must be applied to any macaroon granting capability
+ to execute the operations. Those caveat conditions will not
+ include any declarations contained in login macaroons - the
+ caller must be careful not to mint a macaroon associated
+ with the LOGIN_OP operation unless they add the expected
+ declaration caveat too - in general, clients should not create
+ capabilities that grant LOGIN_OP rights.
+
+ The operations must include at least one non-LOGIN_OP operation.
+ '''
+ nops = 0
+ for op in ops:
+ if op != LOGIN_OP:
+ nops += 1
+ if nops == 0:
+ raise ValueError('no non-login operations required in capability')
+
+ _, used = self._allow_any(ctx, ops)
+ squasher = _CaveatSquasher()
+ for i, is_used in enumerate(used):
+ if not is_used:
+ continue
+ for cond in self._conditions[i]:
+ squasher.add(cond)
+ return squasher.final()
+
+ def _check_conditions(self, ctx, op, conds):
+ declared = checkers.infer_declared_from_conditions(
+ conds,
+ self.parent.namespace())
+ ctx = checkers.context_with_operations(ctx, [op.action])
+ ctx = checkers.context_with_declared(ctx, declared)
+ for cond in conds:
+ err = self.parent._first_party_caveat_checker.\
+ check_first_party_caveat(ctx, cond)
+ if err is not None:
+ return None, err
+ return declared, None
+
+
+class AuthInfo(namedtuple('AuthInfo', 'identity macaroons')):
+ '''AuthInfo information about an authorization decision.
+
+ @param identity: holds information on the authenticated user as
+ returned identity_client. It may be None after a successful
+ authorization if LOGIN_OP access was not required.
+
+ @param macaroons: holds all the macaroons that were used for the
+ authorization. Macaroons that were invalid or unnecessary are
+ not included.
+ '''
+
+
+class _CaveatSquasher(object):
+ ''' Rationalizes first party caveats created for a capability by:
+ - including only the earliest time-before caveat.
+ - excluding allow and deny caveats (operations are checked by
+ virtue of the operations associated with the macaroon).
+ - removing declared caveats.
+ - removing duplicates.
+ '''
+ def __init__(self, expiry=None, conds=None):
+ self._expiry = expiry
+ if conds is None:
+ conds = []
+ self._conds = conds
+
+ def add(self, cond):
+ if self._add(cond):
+ self._conds.append(cond)
+
+ def _add(self, cond):
+ try:
+ cond, args = checkers.parse_caveat(cond)
+ except ValueError:
+ # Be safe - if we can't parse the caveat, just leave it there.
+ return True
+
+ if cond == checkers.COND_TIME_BEFORE:
+ try:
+ et = pyrfc3339.parse(args, utc=True).replace(tzinfo=None)
+ except ValueError:
+ # Again, if it doesn't seem valid, leave it alone.
+ return True
+ if self._expiry is None or et <= self._expiry:
+ self._expiry = et
+ return False
+ elif cond in [checkers.COND_ALLOW,
+ checkers.COND_DENY, checkers.COND_DECLARED]:
+ return False
+ return True
+
+ def final(self):
+ if self._expiry is not None:
+ self._conds.append(
+ checkers.time_before_caveat(self._expiry).condition)
+ # Make deterministic and eliminate duplicates.
+ return sorted(set(self._conds))
diff --git a/macaroonbakery/bakery/_codec.py b/macaroonbakery/bakery/_codec.py
new file mode 100644
index 0000000..903e604
--- /dev/null
+++ b/macaroonbakery/bakery/_codec.py
@@ -0,0 +1,301 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+import base64
+import json
+
+from ._versions import (VERSION_1, VERSION_2, VERSION_3)
+from ._third_party import legacy_namespace, ThirdPartyCaveatInfo
+from ._keys import PublicKey
+from ._error import VerificationError
+import macaroonbakery.checkers as checkers
+import nacl.public
+import six
+
+_PUBLIC_KEY_PREFIX_LEN = 4
+_KEY_LEN = 32
+# version3CaveatMinLen holds an underestimate of the
+# minimum length of a version 3 caveat.
+_VERSION3_CAVEAT_MIN_LEN = 1 + 4 + 32 + 24 + 16 + 1
+
+
+def encode_caveat(condition, root_key, third_party_info, key, ns):
+ '''Encrypt a third-party caveat.
+
+ The third_party_info key holds information about the
+ third party we're encrypting the caveat for; the key is the
+ public/private key pair of the party that's adding the caveat.
+
+ The caveat will be encoded according to the version information
+ found in third_party_info.
+
+ @param condition string
+ @param root_key bytes
+ @param third_party_info object
+ @param key nacl key
+ @param ns not used yet
+ @return bytes
+ '''
+ if third_party_info.version == VERSION_1:
+ return _encode_caveat_v1(condition, root_key,
+ third_party_info.public_key, key)
+ if (third_party_info.version == VERSION_2 or
+ third_party_info.version == VERSION_3):
+ return _encode_caveat_v2_v3(third_party_info.version, condition,
+ root_key, third_party_info.public_key,
+ key, ns)
+ raise NotImplementedError('only bakery v1, v2, v3 supported')
+
+
+def _encode_caveat_v1(condition, root_key, third_party_pub_key, key):
+ '''Create a JSON-encoded third-party caveat.
+
+ The third_party_pub_key key represents the PublicKey of the third party
+ we're encrypting the caveat for; the key is the public/private key pair of
+ the party that's adding the caveat.
+
+ @param condition string
+ @param root_key bytes
+ @param third_party_pub_key (PublicKey)
+ @param key (PrivateKey)
+ @return a base64 encoded bytes
+ '''
+ plain_data = json.dumps({
+ 'RootKey': base64.b64encode(root_key).decode('ascii'),
+ 'Condition': condition
+ })
+ box = nacl.public.Box(key.key, third_party_pub_key.key)
+
+ encrypted = box.encrypt(six.b(plain_data))
+ nonce = encrypted[0:nacl.public.Box.NONCE_SIZE]
+ encrypted = encrypted[nacl.public.Box.NONCE_SIZE:]
+ return base64.b64encode(six.b(json.dumps({
+ 'ThirdPartyPublicKey': str(third_party_pub_key),
+ 'FirstPartyPublicKey': str(key.public_key),
+ 'Nonce': base64.b64encode(nonce).decode('ascii'),
+ 'Id': base64.b64encode(encrypted).decode('ascii')
+ })))
+
+
+def _encode_caveat_v2_v3(version, condition, root_key, third_party_pub_key,
+ key, ns):
+ '''Create a version 2 or version 3 third-party caveat.
+
+ The format has the following packed binary fields (note
+ that all fields up to and including the nonce are the same
+ as the v2 format):
+
+ version 2 or 3 [1 byte]
+ first 4 bytes of third-party Curve25519 public key [4 bytes]
+ first-party Curve25519 public key [32 bytes]
+ nonce [24 bytes]
+ encrypted secret part [rest of message]
+
+ The encrypted part encrypts the following fields
+ with box.Seal:
+
+ version 2 or 3 [1 byte]
+ length of root key [n: uvarint]
+ root key [n bytes]
+ length of encoded namespace [n: uvarint] (Version 3 only)
+ encoded namespace [n bytes] (Version 3 only)
+ condition [rest of encrypted part]
+ '''
+ ns_data = bytearray()
+ if version >= VERSION_3:
+ ns_data = ns.serialize_text()
+ data = bytearray()
+ data.append(version)
+ data.extend(third_party_pub_key.serialize(raw=True)[:_PUBLIC_KEY_PREFIX_LEN])
+ data.extend(key.public_key.serialize(raw=True)[:])
+ secret = _encode_secret_part_v2_v3(version, condition, root_key, ns_data)
+ box = nacl.public.Box(key.key, third_party_pub_key.key)
+ encrypted = box.encrypt(secret)
+ nonce = encrypted[0:nacl.public.Box.NONCE_SIZE]
+ encrypted = encrypted[nacl.public.Box.NONCE_SIZE:]
+ data.extend(nonce[:])
+ data.extend(encrypted)
+ return bytes(data)
+
+
+def _encode_secret_part_v2_v3(version, condition, root_key, ns):
+ '''Creates a version 2 or version 3 secret part of the third party
+ caveat. The returned data is not encrypted.
+
+ The format has the following packed binary fields:
+ version 2 or 3 [1 byte]
+ root key length [n: uvarint]
+ root key [n bytes]
+ namespace length [n: uvarint] (v3 only)
+ namespace [n bytes] (v3 only)
+ predicate [rest of message]
+ '''
+ data = bytearray()
+ data.append(version)
+ encode_uvarint(len(root_key), data)
+ data.extend(root_key)
+ if version >= VERSION_3:
+ encode_uvarint(len(ns), data)
+ data.extend(ns)
+ data.extend(condition.encode('utf-8'))
+ return bytes(data)
+
+
+def decode_caveat(key, caveat):
+ '''Decode caveat by decrypting the encrypted part using key.
+
+ @param key the nacl private key to decode.
+ @param caveat bytes.
+ @return ThirdPartyCaveatInfo
+ '''
+ if len(caveat) == 0:
+ raise VerificationError('empty third party caveat')
+
+ first = caveat[:1]
+ if first == b'e':
+ # 'e' will be the first byte if the caveatid is a base64
+ # encoded JSON object.
+ return _decode_caveat_v1(key, caveat)
+ first_as_int = six.byte2int(first)
+ if (first_as_int == VERSION_2 or
+ first_as_int == VERSION_3):
+ if (len(caveat) < _VERSION3_CAVEAT_MIN_LEN
+ and first_as_int == VERSION_3):
+ # If it has the version 3 caveat tag and it's too short, it's
+ # almost certainly an id, not an encrypted payload.
+ raise VerificationError(
+ 'caveat id payload not provided for caveat id {}'.format(
+ caveat))
+ return _decode_caveat_v2_v3(first_as_int, key, caveat)
+ raise VerificationError('unknown version for caveat')
+
+
+def _decode_caveat_v1(key, caveat):
+ '''Decode a base64 encoded JSON id.
+
+ @param key the nacl private key to decode.
+ @param caveat a base64 encoded JSON string.
+ '''
+
+ data = base64.b64decode(caveat).decode('utf-8')
+ wrapper = json.loads(data)
+ tp_public_key = nacl.public.PublicKey(
+ base64.b64decode(wrapper['ThirdPartyPublicKey']))
+ if key.public_key.key != tp_public_key:
+ raise Exception('public key mismatch') # TODO
+
+ if wrapper.get('FirstPartyPublicKey', None) is None:
+ raise Exception('target service public key not specified')
+
+ # The encrypted string is base64 encoded in the JSON representation.
+ secret = base64.b64decode(wrapper.get('Id'))
+ nonce = base64.b64decode(wrapper.get('Nonce'))
+
+ fp_public_key = nacl.public.PublicKey(base64.b64decode(
+ wrapper.get('FirstPartyPublicKey')))
+
+ box = nacl.public.Box(key.key, fp_public_key)
+ c = box.decrypt(secret, nonce)
+ record = json.loads(c.decode('utf-8'))
+ fp_key = nacl.public.PublicKey(
+ base64.b64decode(wrapper.get('FirstPartyPublicKey')))
+ return ThirdPartyCaveatInfo(
+ condition=record.get('Condition'),
+ first_party_public_key=PublicKey(fp_key),
+ third_party_key_pair=key,
+ root_key=base64.b64decode(record.get('RootKey')),
+ caveat=caveat,
+ id=None,
+ version=VERSION_1,
+ namespace=legacy_namespace()
+ )
+
+
+def _decode_caveat_v2_v3(version, key, caveat):
+ '''Decodes a version 2 or version 3 caveat.
+ '''
+ if (len(caveat) < 1 + _PUBLIC_KEY_PREFIX_LEN +
+ _KEY_LEN + nacl.public.Box.NONCE_SIZE + 16):
+ raise VerificationError('caveat id too short')
+ original_caveat = caveat
+ caveat = caveat[1:] # skip version (already checked)
+
+ pk_prefix = caveat[:_PUBLIC_KEY_PREFIX_LEN]
+ caveat = caveat[_PUBLIC_KEY_PREFIX_LEN:]
+ if key.public_key.serialize(raw=True)[:_PUBLIC_KEY_PREFIX_LEN] != pk_prefix:
+ raise VerificationError('public key mismatch')
+
+ first_party_pub = caveat[:_KEY_LEN]
+ caveat = caveat[_KEY_LEN:]
+ nonce = caveat[:nacl.public.Box.NONCE_SIZE]
+ caveat = caveat[nacl.public.Box.NONCE_SIZE:]
+ fp_public_key = nacl.public.PublicKey(first_party_pub)
+ box = nacl.public.Box(key.key, fp_public_key)
+ data = box.decrypt(caveat, nonce)
+ root_key, condition, ns = _decode_secret_part_v2_v3(version, data)
+ return ThirdPartyCaveatInfo(
+ condition=condition.decode('utf-8'),
+ first_party_public_key=PublicKey(fp_public_key),
+ third_party_key_pair=key,
+ root_key=root_key,
+ caveat=original_caveat,
+ version=version,
+ id=None,
+ namespace=ns
+ )
+
+
+def _decode_secret_part_v2_v3(version, data):
+ if len(data) < 1:
+ raise VerificationError('secret part too short')
+ got_version = six.byte2int(data[:1])
+ data = data[1:]
+ if version != got_version:
+ raise VerificationError(
+ 'unexpected secret part version, got {} want {}'.format(
+ got_version, version))
+ root_key_length, read = decode_uvarint(data)
+ data = data[read:]
+ root_key = data[:root_key_length]
+ data = data[root_key_length:]
+ if version >= VERSION_3:
+ namespace_length, read = decode_uvarint(data)
+ data = data[read:]
+ ns_data = data[:namespace_length]
+ data = data[namespace_length:]
+ ns = checkers.deserialize_namespace(ns_data)
+ else:
+ ns = legacy_namespace()
+ return root_key, data, ns
+
+
+def encode_uvarint(n, data):
+ '''encodes integer into variable-length format into data.'''
+ if n < 0:
+ raise ValueError('only support positive integer')
+ while True:
+ this_byte = n & 127
+ n >>= 7
+ if n == 0:
+ data.append(this_byte)
+ break
+ data.append(this_byte | 128)
+
+
+def decode_uvarint(data):
+ '''Decode a variable-length integer.
+
+ Reads a sequence of unsigned integer byte and decodes them into an integer
+ in variable-length format and returns it and the length read.
+ '''
+ n = 0
+ shift = 0
+ length = 0
+ for b in data:
+ if not isinstance(b, int):
+ b = six.byte2int(b)
+ n |= (b & 0x7f) << shift
+ length += 1
+ if (b & 0x80) == 0:
+ break
+ shift += 7
+ return n, length
diff --git a/macaroonbakery/bakery/_discharge.py b/macaroonbakery/bakery/_discharge.py
new file mode 100644
index 0000000..1831209
--- /dev/null
+++ b/macaroonbakery/bakery/_discharge.py
@@ -0,0 +1,241 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+import abc
+from collections import namedtuple
+
+from ._error import (
+ ThirdPartyCaveatCheckFailed,
+ CaveatNotRecognizedError,
+ VerificationError,
+)
+from ._codec import decode_caveat
+from ._macaroon import (
+ Macaroon,
+ ThirdPartyLocator,
+)
+from ._versions import VERSION_2
+from ._third_party import ThirdPartyCaveatInfo
+
+import macaroonbakery.checkers as checkers
+
+emptyContext = checkers.AuthContext()
+
+
+def discharge_all(m, get_discharge, local_key=None):
+ '''Gathers discharge macaroons for all the third party caveats in m
+ (and any subsequent caveats required by those) using get_discharge to
+ acquire each discharge macaroon.
+ The local_key parameter may optionally hold the key of the client, in
+ which case it will be used to discharge any third party caveats with the
+ special location "local". In this case, the caveat itself must be "true".
+ This can be used be a server to ask a client to prove ownership of the
+ private key.
+ It returns a list of macaroon with m as the first element, followed by all
+ the discharge macaroons.
+ All the discharge macaroons will be bound to the primary macaroon.
+ The get_discharge function is passed a context (AuthContext),
+ the caveat(Caveat) to be discharged and encrypted_caveat (bytes)will be
+ passed the external caveat payload found in m, if any.
+ '''
+ primary = m.macaroon
+ discharges = [primary]
+
+ # cav holds the macaroon caveat that needs discharge.
+ # encrypted_caveat (bytes) holds encrypted caveat if it was held
+ # externally.
+ _NeedCaveat = namedtuple('_NeedCaveat', 'cav encrypted_caveat')
+ need = []
+
+ def add_caveats(m):
+ for cav in m.macaroon.caveats:
+ if cav.location is None or cav.location == '':
+ continue
+ encrypted_caveat = m.caveat_data.get(cav.caveat_id, None)
+ need.append(
+ _NeedCaveat(cav=cav,
+ encrypted_caveat=encrypted_caveat))
+ add_caveats(m)
+ while len(need) > 0:
+ cav = need[0]
+ need = need[1:]
+ if cav.cav.location == 'local':
+ if local_key is None:
+ raise ThirdPartyCaveatCheckFailed(
+ 'found local third party caveat but no private key provided',
+ )
+ # TODO use a small caveat id.
+ dm = discharge(ctx=emptyContext,
+ key=local_key,
+ checker=_LocalDischargeChecker(),
+ caveat=cav.encrypted_caveat,
+ id=cav.cav.caveat_id_bytes,
+ locator=_EmptyLocator())
+ else:
+ dm = get_discharge(cav.cav, cav.encrypted_caveat)
+ # It doesn't matter that we're invalidating dm here because we're
+ # about to throw it away.
+ discharge_m = dm.macaroon
+ m = primary.prepare_for_request(discharge_m)
+ discharges.append(m)
+ add_caveats(dm)
+ return discharges
+
+
+class ThirdPartyCaveatChecker(object):
+ ''' Defines an abstract class that's used to check third party caveats.
+ '''
+ __metaclass__ = abc.ABCMeta
+
+ @abc.abstractmethod
+ def check_third_party_caveat(self, ctx, info):
+ ''' If the caveat is valid, it returns optionally a slice of
+ extra caveats that will be added to the discharge macaroon.
+ If the caveat kind was not recognised, the checker should
+ raise a CaveatNotRecognized exception; if the check failed,
+ it should raise a ThirdPartyCaveatCheckFailed exception.
+ :param ctx (AuthContext)
+ :param info (ThirdPartyCaveatInfo) holds the information decoded from
+ a third party caveat id
+ :return: An array of extra caveats to be added to the discharge
+ macaroon.
+ '''
+ raise NotImplementedError('check_third_party_caveat method must be '
+ 'defined in subclass')
+
+
+class _LocalDischargeChecker(ThirdPartyCaveatChecker):
+ def check_third_party_caveat(self, ctx, info):
+ if info.condition != 'true':
+ raise CaveatNotRecognizedError()
+ return []
+
+
+def discharge(ctx, id, caveat, key, checker, locator):
+ ''' Creates a macaroon to discharge a third party caveat.
+
+ The given parameters specify the caveat and how it should be checked.
+ The condition implicit in the caveat is checked for validity using checker.
+ If it is valid, a new macaroon is returned which discharges the caveat.
+ The macaroon is created with a version derived from the version that was
+ used to encode the id.
+
+ :param id: (bytes) holds the id to give to the discharge macaroon.
+ If Caveat is empty, then the id also holds the encrypted third party
+ caveat.
+ :param caveat: (bytes) holds the encrypted third party caveat.
+ If this is None, id will be used.
+ :param key: holds the key to use to decrypt the third party caveat
+ information and to encrypt any additional third party caveats returned by
+ the caveat checker.
+ :param checker: used to check the third party caveat, and may also return
+ further caveats to be added to the discharge macaroon.
+ :param locator: used to information on third parties referred to by third
+ party caveats returned by the Checker.
+ '''
+ caveat_id_prefix = []
+ if caveat is None:
+ # The caveat information is encoded in the id itself.
+ caveat = id
+ else:
+ # We've been given an explicit id, so when extra third party
+ # caveats are added, use that id as the prefix
+ # for any more ids.
+ caveat_id_prefix = id
+ cav_info = decode_caveat(key, caveat)
+ cav_info = ThirdPartyCaveatInfo(
+ condition=cav_info.condition,
+ first_party_public_key=cav_info.first_party_public_key,
+ third_party_key_pair=cav_info.third_party_key_pair,
+ root_key=cav_info.root_key,
+ caveat=cav_info.caveat,
+ version=cav_info.version,
+ id=id,
+ namespace=cav_info.namespace
+ )
+ # Note that we don't check the error - we allow the
+ # third party checker to see even caveats that we can't
+ # understand.
+ try:
+ cond, arg = checkers.parse_caveat(cav_info.condition)
+ except ValueError as exc:
+ raise VerificationError(exc.args[0])
+
+ if cond == checkers.COND_NEED_DECLARED:
+ cav_info = cav_info._replace(condition=arg.encode('utf-8'))
+ caveats = _check_need_declared(ctx, cav_info, checker)
+ else:
+ caveats = checker.check_third_party_caveat(ctx, cav_info)
+
+ # Note that the discharge macaroon does not need to
+ # be stored persistently. Indeed, it would be a problem if
+ # we did, because then the macaroon could potentially be used
+ # for normal authorization with the third party.
+ m = Macaroon(
+ cav_info.root_key,
+ id,
+ '',
+ cav_info.version,
+ cav_info.namespace,
+ )
+ m._caveat_id_prefix = caveat_id_prefix
+ if caveats is not None:
+ for cav in caveats:
+ m.add_caveat(cav, key, locator)
+ return m
+
+
+def _check_need_declared(ctx, cav_info, checker):
+ arg = cav_info.condition.decode('utf-8')
+ i = arg.find(' ')
+ if i <= 0:
+ raise VerificationError(
+ 'need-declared caveat requires an argument, got %q'.format(arg),
+ )
+ need_declared = arg[0:i].split(',')
+ for d in need_declared:
+ if d == '':
+ raise VerificationError('need-declared caveat with empty required attribute')
+ if len(need_declared) == 0:
+ raise VerificationError('need-declared caveat with no required attributes')
+ cav_info = cav_info._replace(condition=arg[i + 1:].encode('utf-8'))
+ caveats = checker.check_third_party_caveat(ctx, cav_info)
+ declared = {}
+ for cav in caveats:
+ if cav.location is not None and cav.location != '':
+ continue
+ # Note that we ignore the error. We allow the service to
+ # generate caveats that we don't understand here.
+ try:
+ cond, arg = checkers.parse_caveat(cav.condition)
+ except ValueError:
+ continue
+ if cond != checkers.COND_DECLARED:
+ continue
+ parts = arg.split()
+ if len(parts) != 2:
+ raise VerificationError('declared caveat has no value')
+ declared[parts[0]] = True
+ # Add empty declarations for everything mentioned in need-declared
+ # that was not actually declared.
+ for d in need_declared:
+ if not declared.get(d, False):
+ caveats.append(checkers.declared_caveat(d, ''))
+ return caveats
+
+
+class _EmptyLocator(ThirdPartyLocator):
+ def third_party_info(self, loc):
+ return None
+
+
+def local_third_party_caveat(key, version):
+ ''' Returns a third-party caveat that, when added to a macaroon with
+ add_caveat, results in a caveat with the location "local", encrypted with
+ the given PublicKey.
+ This can be automatically discharged by discharge_all passing a local key.
+ '''
+ if version >= VERSION_2:
+ loc = 'local {} {}'.format(version, key)
+ else:
+ loc = 'local {}'.format(key)
+ return checkers.Caveat(location=loc, condition='')
diff --git a/macaroonbakery/bakery/_error.py b/macaroonbakery/bakery/_error.py
new file mode 100644
index 0000000..b403569
--- /dev/null
+++ b/macaroonbakery/bakery/_error.py
@@ -0,0 +1,77 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+
+
+class DischargeRequiredError(Exception):
+ ''' Raised by checker when authorization has failed and a discharged
+ macaroon might fix it.
+
+ A caller should grant the user the ability to authorize by minting a
+ macaroon associated with Ops (see MacaroonStore.MacaroonIdInfo for
+ how the associated operations are retrieved) and adding Caveats. If
+ the user succeeds in discharging the caveats, the authorization will
+ be granted.
+ '''
+ def __init__(self, msg, ops, cavs):
+ '''
+ :param msg: holds some reason why the authorization was denied.
+ :param ops: holds all the operations that were not authorized.
+ If ops contains a single LOGIN_OP member, the macaroon
+ should be treated as an login token. Login tokens (also
+ known as authentication macaroons) usually have a longer
+ life span than other macaroons.
+ :param cavs: holds the caveats that must be added to macaroons that
+ authorize the above operations.
+ '''
+ super(DischargeRequiredError, self).__init__(msg)
+ self._ops = ops
+ self._cavs = cavs
+
+ def ops(self):
+ return self._ops
+
+ def cavs(self):
+ return self._cavs
+
+
+class PermissionDenied(Exception):
+ '''Raised from AuthChecker when permission has been denied.
+ '''
+ pass
+
+
+class CaveatNotRecognizedError(Exception):
+ '''Containing the cause of errors returned from caveat checkers when the
+ caveat was not recognized.
+ '''
+ pass
+
+
+class VerificationError(Exception):
+ '''Raised to signify that an error is because of a verification failure
+ rather than because verification could not be done.'''
+ pass
+
+
+class AuthInitError(Exception):
+ '''Raised if AuthChecker cannot be initialized properly.'''
+ pass
+
+
+class IdentityError(Exception):
+ ''' Raised from IdentityClient.declared_identity when an error occurs.
+ '''
+ pass
+
+
+class ThirdPartyCaveatCheckFailed(Exception):
+ ''' Raised from ThirdPartyCaveatChecker.check_third_party when check fails.
+ '''
+ pass
+
+
+class ThirdPartyInfoNotFound(Exception):
+ ''' Raised from implementation of ThirdPartyLocator.third_party_info when
+ the info cannot be found.
+ '''
+ pass
diff --git a/macaroonbakery/bakery/_identity.py b/macaroonbakery/bakery/_identity.py
new file mode 100644
index 0000000..4389cd9
--- /dev/null
+++ b/macaroonbakery/bakery/_identity.py
@@ -0,0 +1,126 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+import abc
+
+from ._error import IdentityError
+
+
+class Identity(object):
+ ''' Holds identity information declared in a first party caveat added when
+ discharging a third party caveat.
+ '''
+ __metaclass__ = abc.ABCMeta
+
+ @abc.abstractmethod
+ def id(self):
+ ''' Returns the id of the user.
+
+ May be an opaque blob with no human meaning. An id is only considered
+ to be unique with a given domain.
+ :return string
+ '''
+ raise NotImplementedError('id method must be defined in subclass')
+
+ @abc.abstractmethod
+ def domain(self):
+ '''Return the domain of the user.
+
+ This will be empty if the user was authenticated
+ directly with the identity provider.
+ :return string
+ '''
+ raise NotImplementedError('domain method must be defined in subclass')
+
+
+class ACLIdentity(Identity):
+ ''' ACLIdentity may be implemented by Identity implementations
+ to report group membership information.
+ '''
+ __metaclass__ = abc.ABCMeta
+
+ @abc.abstractmethod
+ def allow(self, ctx, acls):
+ ''' reports whether the user should be allowed to access
+ any of the users or groups in the given acl list.
+ :param ctx(AuthContext) is the context of the authorization request.
+ :param acls array of string acl
+ :return boolean
+ '''
+ raise NotImplementedError('allow method must be defined in subclass')
+
+
+class SimpleIdentity(ACLIdentity):
+ ''' A simple form of identity where the user is represented by a string.
+ '''
+ def __init__(self, user):
+ self._identity = user
+
+ def domain(self):
+ ''' A simple identity has no domain.
+ '''
+ return ''
+
+ def id(self):
+ '''Return the user name as the id.
+ '''
+ return self._identity
+
+ def allow(self, ctx, acls):
+ '''Allow access to any ACL members that was equal to the user name.
+
+ That is, some user u is considered a member of group u and no other.
+ '''
+ for acl in acls:
+ if self._identity == acl:
+ return True
+ return False
+
+
+class IdentityClient(object):
+ ''' Represents an abstract identity manager. User identities can be based
+ on local informaton (for example HTTP basic auth) or by reference to an
+ external trusted third party (an identity manager).
+ '''
+ __metaclass__ = abc.ABCMeta
+
+ @abc.abstractmethod
+ def identity_from_context(self, ctx):
+ ''' Returns the identity based on information in the context.
+
+ If it cannot determine the identity based on the context, then it
+ should return a set of caveats containing a third party caveat that,
+ when discharged, can be used to obtain the identity with
+ declared_identity.
+
+ It should only raise an error if it cannot check the identity
+ (for example because of a database access error) - it's
+ OK to return all zero values when there's
+ no identity found and no third party to address caveats to.
+ @param ctx an AuthContext
+ :return: an Identity and array of caveats
+ '''
+ raise NotImplementedError('identity_from_context method must be '
+ 'defined in subclass')
+
+ @abc.abstractmethod
+ def declared_identity(self, ctx, declared):
+ '''Parses the identity declaration from the given declared attributes.
+
+ TODO take the set of first party caveat conditions instead?
+ @param ctx (AuthContext)
+ @param declared (dict of string/string)
+ :return: an Identity
+ '''
+ raise NotImplementedError('declared_identity method must be '
+ 'defined in subclass')
+
+
+class NoIdentities(IdentityClient):
+ ''' Defines the null identity provider - it never returns any identities.
+ '''
+
+ def identity_from_context(self, ctx):
+ return None, None
+
+ def declared_identity(self, ctx, declared):
+ raise IdentityError('no identity declared or possible')
diff --git a/macaroonbakery/bakery/_internal/__init__.py b/macaroonbakery/bakery/_internal/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/macaroonbakery/bakery/_internal/__init__.py
diff --git a/macaroonbakery/bakery/_internal/id.proto b/macaroonbakery/bakery/_internal/id.proto
new file mode 100644
index 0000000..eb3d614
--- /dev/null
+++ b/macaroonbakery/bakery/_internal/id.proto
@@ -0,0 +1,14 @@
+syntax="proto3";
+
+option go_package = "macaroonpb";
+
+message MacaroonId {
+ bytes nonce = 1;
+ bytes storageId = 2;
+ repeated Op ops = 3;
+}
+
+message Op {
+ string entity = 1;
+ repeated string actions = 2;
+}
diff --git a/macaroonbakery/bakery/_internal/id_pb2.py b/macaroonbakery/bakery/_internal/id_pb2.py
new file mode 100644
index 0000000..0fd54c0
--- /dev/null
+++ b/macaroonbakery/bakery/_internal/id_pb2.py
@@ -0,0 +1,132 @@
+# Generated by the protocol buffer compiler. DO NOT EDIT!
+# source: macaroonbakery/internal/id.proto
+
+import sys
+_b=sys.version_info[0]<3 and (lambda x:x) or (lambda x:x.encode('latin1'))
+from google.protobuf import descriptor as _descriptor
+from google.protobuf import message as _message
+from google.protobuf import reflection as _reflection
+from google.protobuf import symbol_database as _symbol_database
+from google.protobuf import descriptor_pb2
+# @@protoc_insertion_point(imports)
+
+_sym_db = _symbol_database.Default()
+
+
+
+
+DESCRIPTOR = _descriptor.FileDescriptor(
+ name='macaroonbakery/internal/id.proto',
+ package='',
+ syntax='proto3',
+ serialized_pb=_b('\n macaroonbakery/internal/id.proto\"@\n\nMacaroonId\x12\r\n\x05nonce\x18\x01 \x01(\x0c\x12\x11\n\tstorageId\x18\x02 \x01(\x0c\x12\x10\n\x03ops\x18\x03 \x03(\x0b\x32\x03.Op\"%\n\x02Op\x12\x0e\n\x06\x65ntity\x18\x01 \x01(\t\x12\x0f\n\x07\x61\x63tions\x18\x02 \x03(\tB\x0cZ\nmacaroonpbb\x06proto3')
+)
+
+
+
+
+_MACAROONID = _descriptor.Descriptor(
+ name='MacaroonId',
+ full_name='MacaroonId',
+ filename=None,
+ file=DESCRIPTOR,
+ containing_type=None,
+ fields=[
+ _descriptor.FieldDescriptor(
+ name='nonce', full_name='MacaroonId.nonce', index=0,
+ number=1, type=12, cpp_type=9, label=1,
+ has_default_value=False, default_value=_b(""),
+ message_type=None, enum_type=None, containing_type=None,
+ is_extension=False, extension_scope=None,
+ options=None),
+ _descriptor.FieldDescriptor(
+ name='storageId', full_name='MacaroonId.storageId', index=1,
+ number=2, type=12, cpp_type=9, label=1,
+ has_default_value=False, default_value=_b(""),
+ message_type=None, enum_type=None, containing_type=None,
+ is_extension=False, extension_scope=None,
+ options=None),
+ _descriptor.FieldDescriptor(
+ name='ops', full_name='MacaroonId.ops', index=2,
+ number=3, type=11, cpp_type=10, label=3,
+ has_default_value=False, default_value=[],
+ message_type=None, enum_type=None, containing_type=None,
+ is_extension=False, extension_scope=None,
+ options=None),
+ ],
+ extensions=[
+ ],
+ nested_types=[],
+ enum_types=[
+ ],
+ options=None,
+ is_extendable=False,
+ syntax='proto3',
+ extension_ranges=[],
+ oneofs=[
+ ],
+ serialized_start=36,
+ serialized_end=100,
+)
+
+
+_OP = _descriptor.Descriptor(
+ name='Op',
+ full_name='Op',
+ filename=None,
+ file=DESCRIPTOR,
+ containing_type=None,
+ fields=[
+ _descriptor.FieldDescriptor(
+ name='entity', full_name='Op.entity', index=0,
+ number=1, type=9, cpp_type=9, label=1,
+ has_default_value=False, default_value=_b("").decode('utf-8'),
+ message_type=None, enum_type=None, containing_type=None,
+ is_extension=False, extension_scope=None,
+ options=None),
+ _descriptor.FieldDescriptor(
+ name='actions', full_name='Op.actions', index=1,
+ number=2, type=9, cpp_type=9, label=3,
+ has_default_value=False, default_value=[],
+ message_type=None, enum_type=None, containing_type=None,
+ is_extension=False, extension_scope=None,
+ options=None),
+ ],
+ extensions=[
+ ],
+ nested_types=[],
+ enum_types=[
+ ],
+ options=None,
+ is_extendable=False,
+ syntax='proto3',
+ extension_ranges=[],
+ oneofs=[
+ ],
+ serialized_start=102,
+ serialized_end=139,
+)
+
+_MACAROONID.fields_by_name['ops'].message_type = _OP
+DESCRIPTOR.message_types_by_name['MacaroonId'] = _MACAROONID
+DESCRIPTOR.message_types_by_name['Op'] = _OP
+_sym_db.RegisterFileDescriptor(DESCRIPTOR)
+
+MacaroonId = _reflection.GeneratedProtocolMessageType('MacaroonId', (_message.Message,), dict(
+ DESCRIPTOR = _MACAROONID,
+ __module__ = 'macaroonbakery.internal.id_pb2'
+ # @@protoc_insertion_point(class_scope:MacaroonId)
+ ))
+_sym_db.RegisterMessage(MacaroonId)
+
+Op = _reflection.GeneratedProtocolMessageType('Op', (_message.Message,), dict(
+ DESCRIPTOR = _OP,
+ __module__ = 'macaroonbakery.internal.id_pb2'
+ # @@protoc_insertion_point(class_scope:Op)
+ ))
+_sym_db.RegisterMessage(Op)
+
+
+DESCRIPTOR.has_options = True
+DESCRIPTOR._options = _descriptor._ParseOptions(descriptor_pb2.FileOptions(), _b('Z\nmacaroonpb'))
+# @@protoc_insertion_point(module_scope)
diff --git a/macaroonbakery/bakery/_keys.py b/macaroonbakery/bakery/_keys.py
new file mode 100644
index 0000000..1da5f05
--- /dev/null
+++ b/macaroonbakery/bakery/_keys.py
@@ -0,0 +1,100 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+
+import nacl.public
+
+
+class PrivateKey(object):
+ ''' A private key used by the bakery to encrypt and decrypt
+ third party caveats.
+ Internally, it is a 256-bit Ed25519 private key.
+ '''
+ def __init__(self, key):
+ self._key = key
+
+ @property
+ def key(self):
+ ''' Internal nacl key representation.
+ '''
+ return self._key
+
+ @property
+ def public_key(self):
+ '''
+ :return: the PublicKey associated with the private key.
+ '''
+ return PublicKey(self._key.public_key)
+
+ @classmethod
+ def deserialize(cls, serialized):
+ ''' Create a PrivateKey from a base64 encoded bytes.
+ :return: a PrivateKey
+ '''
+ return PrivateKey(
+ nacl.public.PrivateKey(serialized,
+ encoder=nacl.encoding.Base64Encoder))
+
+ def serialize(self, raw=False):
+ '''Encode the private part of the key in a base64 format by default,
+ but when raw is True it will return hex encoded bytes.
+ @return: bytes
+ '''
+ if raw:
+ return self._key.encode()
+ return self._key.encode(nacl.encoding.Base64Encoder)
+
+ def __str__(self):
+ '''Return the private part of the key key as a base64-encoded string'''
+ return self.serialize().decode('utf-8')
+
+ def __eq__(self, other):
+ return self.key == other.key
+
+
+class PublicKey(object):
+ ''' A public key used by the bakery to encrypt third party caveats.
+
+ Every discharger is associated with a public key which is used to
+ encrypt third party caveat ids addressed to that discharger.
+ Internally, it is a 256 bit Ed25519 public key.
+ '''
+ def __init__(self, key):
+ self._key = key
+
+ @property
+ def key(self):
+ ''' Internal nacl key representation.
+ '''
+ return self._key
+
+ def serialize(self, raw=False):
+ '''Encode the private part of the key in a base64 format by default,
+ but when raw is True it will return hex encoded bytes.
+ @return: bytes
+ '''
+ if raw:
+ return self._key.encode()
+ return self._key.encode(nacl.encoding.Base64Encoder)
+
+ def __str__(self):
+ '''Return the key as a base64-encoded string'''
+ return self.serialize().decode('utf-8')
+
+ @classmethod
+ def deserialize(cls, serialized):
+ ''' Create a PublicKey from a base64 encoded bytes.
+ :return: a PublicKey
+ '''
+ return PublicKey(
+ nacl.public.PublicKey(serialized,
+ encoder=nacl.encoding.Base64Encoder))
+
+ def __eq__(self, other):
+ return self.key == other.key
+
+
+def generate_key():
+ '''GenerateKey generates a new PrivateKey.
+ :return: a PrivateKey
+ '''
+ return PrivateKey(nacl.public.PrivateKey.generate())
diff --git a/macaroonbakery/bakery/_macaroon.py b/macaroonbakery/bakery/_macaroon.py
new file mode 100644
index 0000000..63091f6
--- /dev/null
+++ b/macaroonbakery/bakery/_macaroon.py
@@ -0,0 +1,430 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+import abc
+import base64
+import json
+import logging
+import os
+
+import macaroonbakery.checkers as checkers
+import pymacaroons
+from macaroonbakery._utils import b64decode
+from pymacaroons.serializers import json_serializer
+from ._versions import (
+ LATEST_VERSION,
+ VERSION_0,
+ VERSION_1,
+ VERSION_2,
+ VERSION_3,
+)
+from ._error import (
+ ThirdPartyInfoNotFound,
+)
+from ._codec import (
+ encode_uvarint,
+ encode_caveat,
+)
+from ._keys import PublicKey
+from ._third_party import (
+ legacy_namespace,
+ ThirdPartyInfo,
+)
+
+log = logging.getLogger(__name__)
+
+
+class Macaroon(object):
+ '''Represent an undischarged macaroon along with its first
+ party caveat namespace and associated third party caveat information
+ which should be passed to the third party when discharging a caveat.
+ '''
+
+ def __init__(self, root_key, id, location=None,
+ version=LATEST_VERSION, namespace=None):
+ '''Creates a new macaroon with the given root key, id and location.
+
+ If the version is more than the latest known version,
+ the latest known version will be used. The namespace should hold the
+ namespace of the service that is creating the macaroon.
+ @param root_key bytes or string
+ @param id bytes or string
+ @param location bytes or string
+ @param version the bakery version.
+ @param namespace is that of the service creating it
+ '''
+ if version > LATEST_VERSION:
+ log.info('use last known version:{} instead of: {}'.format(
+ LATEST_VERSION, version
+ ))
+ version = LATEST_VERSION
+ # m holds the underlying macaroon.
+ self._macaroon = pymacaroons.Macaroon(
+ location=location, key=root_key, identifier=id,
+ version=macaroon_version(version))
+ # version holds the version of the macaroon.
+ self._version = version
+ self._caveat_data = {}
+ if namespace is None:
+ namespace = checkers.Namespace()
+ self._namespace = namespace
+ self._caveat_id_prefix = bytearray()
+
+ @property
+ def macaroon(self):
+ ''' Return the underlying macaroon.
+ '''
+ return self._macaroon
+
+ @property
+ def version(self):
+ return self._version
+
+ @property
+ def namespace(self):
+ return self._namespace
+
+ @property
+ def caveat_data(self):
+ return self._caveat_data
+
+ def add_caveat(self, cav, key=None, loc=None):
+ '''Add a caveat to the macaroon.
+
+ It encrypts it using the given key pair
+ and by looking up the location using the given locator.
+ As a special case, if the caveat's Location field has the prefix
+ "local " the caveat is added as a client self-discharge caveat using
+ the public key base64-encoded in the rest of the location. In this
+ case, the Condition field must be empty. The resulting third-party
+ caveat will encode the condition "true" encrypted with that public
+ key.
+
+ @param cav the checkers.Caveat to be added.
+ @param key the public key to encrypt third party caveat.
+ @param loc locator to find information on third parties when adding
+ third party caveats. It is expected to have a third_party_info method
+ that will be called with a location string and should return a
+ ThirdPartyInfo instance holding the requested information.
+ '''
+ if cav.location is None:
+ self._macaroon.add_first_party_caveat(
+ self.namespace.resolve_caveat(cav).condition)
+ return
+ if key is None:
+ raise ValueError(
+ 'no private key to encrypt third party caveat')
+ local_info = _parse_local_location(cav.location)
+ if local_info is not None:
+ info = local_info
+ if cav.condition is not '':
+ raise ValueError(
+ 'cannot specify caveat condition in '
+ 'local third-party caveat')
+ cav = checkers.Caveat(location='local', condition='true')
+ else:
+ if loc is None:
+ raise ValueError(
+ 'no locator when adding third party caveat')
+ info = loc.third_party_info(cav.location)
+
+ root_key = os.urandom(24)
+
+ # Use the least supported version to encode the caveat.
+ if self._version < info.version:
+ info = ThirdPartyInfo(
+ version=self._version,
+ public_key=info.public_key,
+ )
+
+ caveat_info = encode_caveat(
+ cav.condition, root_key, info, key, self._namespace)
+ if info.version < VERSION_3:
+ # We're encoding for an earlier client or third party which does
+ # not understand bundled caveat info, so use the encoded
+ # caveat information as the caveat id.
+ id = caveat_info
+ else:
+ id = self._new_caveat_id(self._caveat_id_prefix)
+ self._caveat_data[id] = caveat_info
+
+ self._macaroon.add_third_party_caveat(cav.location, root_key, id)
+
+ def add_caveats(self, cavs, key, loc):
+ '''Add an array of caveats to the macaroon.
+
+ This method does not mutate the current object.
+ @param cavs arrary of caveats.
+ @param key the PublicKey to encrypt third party caveat.
+ @param loc locator to find the location object that has a method
+ third_party_info.
+ '''
+ if cavs is None:
+ return
+ for cav in cavs:
+ self.add_caveat(cav, key, loc)
+
+ def serialize_json(self):
+ '''Return a string holding the macaroon data in JSON format.
+ @return a string holding the macaroon data in JSON format
+ '''
+ return json.dumps(self.to_dict())
+
+ def to_dict(self):
+ '''Return a dict representation of the macaroon data in JSON format.
+ @return a dict
+ '''
+ if self.version < VERSION_3:
+ if len(self._caveat_data) > 0:
+ raise ValueError('cannot serialize pre-version3 macaroon with '
+ 'external caveat data')
+ return json.loads(self._macaroon.serialize(
+ json_serializer.JsonSerializer()))
+ serialized = {
+ 'm': json.loads(self._macaroon.serialize(
+ json_serializer.JsonSerializer())),
+ 'v': self._version,
+ }
+ if self._namespace is not None:
+ serialized['ns'] = self._namespace.serialize_text().decode('utf-8')
+ caveat_data = {}
+ for id in self._caveat_data:
+ key = base64.b64encode(id).decode('utf-8')
+ value = base64.b64encode(self._caveat_data[id]).decode('utf-8')
+ caveat_data[key] = value
+ if len(caveat_data) > 0:
+ serialized['cdata'] = caveat_data
+ return serialized
+
+ @classmethod
+ def from_dict(cls, json_dict):
+ '''Return a macaroon obtained from the given dictionary as
+ deserialized from JSON.
+ @param json_dict The deserialized JSON object.
+ '''
+ json_macaroon = json_dict.get('m')
+ if json_macaroon is None:
+ # Try the v1 format if we don't have a macaroon field.
+ m = pymacaroons.Macaroon.deserialize(
+ json.dumps(json_dict), json_serializer.JsonSerializer())
+ macaroon = Macaroon(root_key=None, id=None,
+ namespace=legacy_namespace(),
+ version=_bakery_version(m.version))
+ macaroon._macaroon = m
+ return macaroon
+
+ version = json_dict.get('v', None)
+ if version is None:
+ raise ValueError('no version specified')
+ if (version < VERSION_3 or
+ version > LATEST_VERSION):
+ raise ValueError('unknown bakery version {}'.format(version))
+ m = pymacaroons.Macaroon.deserialize(json.dumps(json_macaroon),
+ json_serializer.JsonSerializer())
+ if m.version != macaroon_version(version):
+ raise ValueError(
+ 'underlying macaroon has inconsistent version; '
+ 'got {} want {}'.format(m.version, macaroon_version(version)))
+ namespace = checkers.deserialize_namespace(json_dict.get('ns'))
+ cdata = json_dict.get('cdata', {})
+ caveat_data = {}
+ for id64 in cdata:
+ id = b64decode(id64)
+ data = b64decode(cdata[id64])
+ caveat_data[id] = data
+ macaroon = Macaroon(root_key=None, id=None,
+ namespace=namespace,
+ version=version)
+ macaroon._caveat_data = caveat_data
+ macaroon._macaroon = m
+ return macaroon
+
+ @classmethod
+ def deserialize_json(cls, serialized_json):
+ '''Return a macaroon deserialized from a string
+ @param serialized_json The string to decode {str}
+ @return {Macaroon}
+ '''
+ serialized = json.loads(serialized_json)
+ return Macaroon.from_dict(serialized)
+
+ def _new_caveat_id(self, base):
+ '''Return a third party caveat id
+
+ This does not duplicate any third party caveat ids already inside
+ macaroon. If base is non-empty, it is used as the id prefix.
+
+ @param base bytes
+ @return bytes
+ '''
+ id = bytearray()
+ if len(base) > 0:
+ id.extend(base)
+ else:
+ # Add a version byte to the caveat id. Technically
+ # this is unnecessary as the caveat-decoding logic
+ # that looks at versions should never see this id,
+ # but if the caveat payload isn't provided with the
+ # payload, having this version gives a strong indication
+ # that the payload has been omitted so we can produce
+ # a better error for the user.
+ id.append(VERSION_3)
+
+ # Iterate through integers looking for one that isn't already used,
+ # starting from n so that if everyone is using this same algorithm,
+ # we'll only perform one iteration.
+ i = len(self._caveat_data)
+ caveats = self._macaroon.caveats
+ while True:
+ # We append a varint to the end of the id and assume that
+ # any client that's created the id that we're using as a base
+ # is using similar conventions - in the worst case they might
+ # end up with a duplicate third party caveat id and thus create
+ # a macaroon that cannot be discharged.
+ temp = id[:]
+ encode_uvarint(i, temp)
+ found = False
+ for cav in caveats:
+ if (cav.verification_key_id is not None
+ and cav.caveat_id == temp):
+ found = True
+ break
+ if not found:
+ return bytes(temp)
+ i += 1
+
+ def first_party_caveats(self):
+ '''Return the first party caveats from this macaroon.
+
+ @return the first party caveats from this macaroon as pymacaroons
+ caveats.
+ '''
+ return self._macaroon.first_party_caveats()
+
+ def third_party_caveats(self):
+ '''Return the third party caveats.
+
+ @return the third party caveats as pymacaroons caveats.
+ '''
+ return self._macaroon.third_party_caveats()
+
+ def copy(self):
+ ''' Returns a copy of the macaroon. Note that the the new
+ macaroon's namespace still points to the same underlying Namespace -
+ copying the macaroon does not make a copy of the namespace.
+ :return a Macaroon
+ '''
+ m1 = Macaroon(None, None, version=self._version,
+ namespace=self._namespace)
+ m1._macaroon = self._macaroon.copy()
+ m1._caveat_data = self._caveat_data.copy()
+ return m1
+
+
+def macaroon_version(bakery_version):
+ '''Return the macaroon version given the bakery version.
+
+ @param bakery_version the bakery version
+ @return macaroon_version the derived macaroon version
+ '''
+ if bakery_version in [VERSION_0, VERSION_1]:
+ return pymacaroons.MACAROON_V1
+ return pymacaroons.MACAROON_V2
+
+
+class ThirdPartyLocator(object):
+ '''Used to find information on third party discharge services.
+ '''
+ __metaclass__ = abc.ABCMeta
+
+ @abc.abstractmethod
+ def third_party_info(self, loc):
+ '''Return information on the third party at the given location.
+ @param loc string
+ @return: a ThirdPartyInfo
+ @raise: ThirdPartyInfoNotFound
+ '''
+ raise NotImplementedError('third_party_info method must be defined in '
+ 'subclass')
+
+
+class ThirdPartyStore(ThirdPartyLocator):
+ ''' Implements a simple in memory ThirdPartyLocator.
+ '''
+ def __init__(self):
+ self._store = {}
+
+ def third_party_info(self, loc):
+ info = self._store.get(loc.rstrip('/'))
+ if info is None:
+ raise ThirdPartyInfoNotFound(
+ 'cannot retrieve the info for location {}'.format(loc))
+ return info
+
+ def add_info(self, loc, info):
+ '''Associates the given information with the given location.
+ It will ignore any trailing slash.
+ @param loc the location as string
+ @param info (ThirdPartyInfo) to store for this location.
+ '''
+ self._store[loc.rstrip('/')] = info
+
+
+def _parse_local_location(loc):
+ '''Parse a local caveat location as generated by LocalThirdPartyCaveat.
+
+ This is of the form:
+
+ local <version> <pubkey>
+
+ where <version> is the bakery version of the client that we're
+ adding the local caveat for.
+
+ It returns None if the location does not represent a local
+ caveat location.
+ @return a ThirdPartyInfo.
+ '''
+ if not (loc.startswith('local ')):
+ return None
+ v = VERSION_1
+ fields = loc.split()
+ fields = fields[1:] # Skip 'local'
+ if len(fields) == 2:
+ try:
+ v = int(fields[0])
+ except ValueError:
+ return None
+ fields = fields[1:]
+ if len(fields) == 1:
+ key = PublicKey.deserialize(fields[0])
+ return ThirdPartyInfo(public_key=key, version=v)
+ return None
+
+
+def _bakery_version(v):
+ # bakery_version returns a bakery version that corresponds to
+ # the macaroon version v. It is necessarily approximate because
+ # several bakery versions can correspond to a single macaroon
+ # version, so it's only of use when decoding legacy formats
+ #
+ # It will raise a ValueError if it doesn't recognize the version.
+ if v == pymacaroons.MACAROON_V1:
+ # Use version 1 because we don't know of any existing
+ # version 0 clients.
+ return VERSION_1
+ elif v == pymacaroons.MACAROON_V2:
+ # Note that this could also correspond to Version 3, but
+ # this logic is explicitly for legacy versions.
+ return VERSION_2
+ else:
+ raise ValueError('unknown macaroon version when deserializing legacy '
+ 'bakery macaroon; got {}'.format(v))
+
+
+class MacaroonJSONEncoder(json.JSONEncoder):
+ def encode(self, m):
+ return m.serialize_json()
+
+
+class MacaroonJSONDecoder(json.JSONDecoder):
+ def decode(self, s, _w=json.decoder.WHITESPACE.match):
+ return Macaroon.deserialize_json(s)
diff --git a/macaroonbakery/bakery/_oven.py b/macaroonbakery/bakery/_oven.py
new file mode 100644
index 0000000..414a164
--- /dev/null
+++ b/macaroonbakery/bakery/_oven.py
@@ -0,0 +1,283 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+
+import base64
+import hashlib
+import itertools
+import os
+
+import google
+from ._checker import (Op, LOGIN_OP)
+from ._store import MemoryKeyStore
+from ._error import VerificationError
+from ._versions import (
+ VERSION_2,
+ VERSION_3,
+ LATEST_VERSION,
+)
+from ._macaroon import (
+ Macaroon,
+ macaroon_version,
+)
+
+import macaroonbakery.checkers as checkers
+import six
+from macaroonbakery._utils import (
+ raw_urlsafe_b64encode,
+ b64decode,
+)
+from ._internal import id_pb2
+from pymacaroons import MACAROON_V2, Verifier
+from pymacaroons.exceptions import (
+ MacaroonInvalidSignatureException,
+ MacaroonUnmetCaveatException,
+)
+
+
+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 = 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(LATEST_VERSION) + \
+ id.SerializeToString()
+
+ if macaroon_version(version) < MACAROON_V2:
+ # The old macaroon format required valid text for the macaroon id,
+ # so base64-encode it.
+ id_bytes = raw_urlsafe_b64encode(id_bytes)
+
+ m = 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 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)
+ try:
+ v.verify(macaroons[0], root_key, macaroons[1:])
+ except (MacaroonUnmetCaveatException,
+ MacaroonInvalidSignatureException) as exc:
+ raise VerificationError(
+ 'verification failed: {}'.format(exc.args[0]))
+
+ 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 = 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 == VERSION_2:
+ # Skip the UUID at the start of the id.
+ storage_id = id[1 + 16:]
+ if first == VERSION_3:
+ try:
+ id1 = id_pb2.MacaroonId.FromString(id[1:])
+ except google.protobuf.message.DecodeError:
+ raise VerificationError(
+ 'no operations found in macaroon')
+ if len(id1.ops) == 0 or len(id1.ops[0].actions) == 0:
+ raise VerificationError(
+ 'no operations found in macaroon')
+
+ ops = []
+ for op in id1.ops:
+ for action in op.actions:
+ ops.append(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, [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
diff --git a/macaroonbakery/bakery/_store.py b/macaroonbakery/bakery/_store.py
new file mode 100644
index 0000000..ae5f7a7
--- /dev/null
+++ b/macaroonbakery/bakery/_store.py
@@ -0,0 +1,77 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+import abc
+import os
+
+
+class MemoryOpsStore:
+ ''' A multi-op store that stores the operations in memory.
+ '''
+ def __init__(self):
+ self._store = {}
+
+ def put_ops(self, key, time, ops):
+ ''' Put an ops only if not already there, otherwise it's a no op.
+ '''
+ if self._store.get(key) is None:
+ self._store[key] = ops
+
+ def get_ops(self, key):
+ ''' Returns ops from the key if found otherwise raises a KeyError.
+ '''
+ ops = self._store.get(key)
+ if ops is None:
+ raise KeyError(
+ 'cannot get operations for {}'.format(key))
+ return ops
+
+
+class RootKeyStore(object):
+ ''' Defines a store for macaroon root keys.
+ '''
+ __metaclass__ = abc.ABCMeta
+
+ @abc.abstractmethod
+ def get(self, id):
+ ''' Returns the root key for the given id.
+ If the item is not there, it returns None.
+ @param id: bytes
+ @return: bytes
+ '''
+ raise NotImplementedError('get method must be defined in '
+ 'subclass')
+
+ @abc.abstractmethod
+ def root_key(self):
+ ''' Returns the root key to be used for making a new macaroon, and an
+ id that can be used to look it up later with the get method.
+ Note that the root keys should remain available for as long as the
+ macaroons using them are valid.
+ Note that there is no need for it to return a new root key for every
+ call - keys may be reused, although some key cycling is over time is
+ advisable.
+ @return: bytes
+ '''
+
+
+class MemoryKeyStore(RootKeyStore):
+ ''' MemoryKeyStore returns an implementation of
+ Store that generates a single key and always
+ returns that from root_key. The same id ("0") is always
+ used.
+ '''
+ def __init__(self, key=None):
+ ''' If the key is not specified a random key will be generated.
+ @param key: bytes
+ '''
+ if key is None:
+ key = os.urandom(24)
+ self._key = key
+
+ def get(self, id):
+ if id != b'0':
+ return None
+ return self._key
+
+ def root_key(self):
+ return self._key, b'0'
diff --git a/macaroonbakery/bakery/_third_party.py b/macaroonbakery/bakery/_third_party.py
new file mode 100644
index 0000000..91eacaf
--- /dev/null
+++ b/macaroonbakery/bakery/_third_party.py
@@ -0,0 +1,57 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+from collections import namedtuple
+
+import macaroonbakery.checkers as checkers
+
+
+def legacy_namespace():
+ ''' Standard namespace for pre-version3 macaroons.
+ '''
+ ns = checkers.Namespace(None)
+ ns.register(checkers.STD_NAMESPACE, '')
+ return ns
+
+
+class ThirdPartyCaveatInfo(namedtuple(
+ 'ThirdPartyCaveatInfo',
+ 'condition, first_party_public_key, third_party_key_pair, root_key, '
+ 'caveat, version, id, namespace')):
+ '''ThirdPartyCaveatInfo holds the information decoded from
+ a third party caveat id.
+
+ @param condition holds the third party condition to be discharged.
+ This is the only field that most third party dischargers will
+ need to consider. {str}
+
+ @param first_party_public_key holds the public key of the party
+ that created the third party caveat. {PublicKey}
+
+ @param third_party_key_pair holds the nacl private used to decrypt
+ the caveat - the key pair of the discharging service. {PrivateKey}
+
+ @param root_key holds the secret root key encoded by the caveat. {bytes}
+
+ @param caveat holds the full caveat id from
+ which all the other fields are derived. {bytes}
+
+ @param version holds the version that was used to encode
+ the caveat id. {number}
+
+ @param id holds the id of the third party caveat (the id that the
+ discharge macaroon should be given). This will differ from Caveat
+ when the caveat information is encoded separately. {bytes}
+
+ @param namespace object that holds the namespace of the first party
+ that created the macaroon, as encoded by the party that added the
+ third party caveat. {checkers.Namespace}
+ '''
+
+
+class ThirdPartyInfo(namedtuple('ThirdPartyInfo', 'version, public_key')):
+ ''' ThirdPartyInfo holds information on a given third party
+ discharge service.
+ @param version The latest bakery protocol version supported
+ by the discharger {number}
+ @param public_key Public key of the third party {PublicKey}
+ '''
diff --git a/macaroonbakery/bakery/_versions.py b/macaroonbakery/bakery/_versions.py
new file mode 100644
index 0000000..7446d31
--- /dev/null
+++ b/macaroonbakery/bakery/_versions.py
@@ -0,0 +1,9 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+
+
+VERSION_0 = 0
+VERSION_1 = 1
+VERSION_2 = 2
+VERSION_3 = 3
+LATEST_VERSION = VERSION_3