diff options
Diffstat (limited to 'macaroonbakery/bakery')
-rw-r--r-- | macaroonbakery/bakery/__init__.py | 141 | ||||
-rw-r--r-- | macaroonbakery/bakery/_authorizer.py | 106 | ||||
-rw-r--r-- | macaroonbakery/bakery/_bakery.py | 72 | ||||
-rw-r--r-- | macaroonbakery/bakery/_checker.py | 417 | ||||
-rw-r--r-- | macaroonbakery/bakery/_codec.py | 301 | ||||
-rw-r--r-- | macaroonbakery/bakery/_discharge.py | 241 | ||||
-rw-r--r-- | macaroonbakery/bakery/_error.py | 77 | ||||
-rw-r--r-- | macaroonbakery/bakery/_identity.py | 126 | ||||
-rw-r--r-- | macaroonbakery/bakery/_internal/__init__.py | 0 | ||||
-rw-r--r-- | macaroonbakery/bakery/_internal/id.proto | 14 | ||||
-rw-r--r-- | macaroonbakery/bakery/_internal/id_pb2.py | 132 | ||||
-rw-r--r-- | macaroonbakery/bakery/_keys.py | 100 | ||||
-rw-r--r-- | macaroonbakery/bakery/_macaroon.py | 430 | ||||
-rw-r--r-- | macaroonbakery/bakery/_oven.py | 283 | ||||
-rw-r--r-- | macaroonbakery/bakery/_store.py | 77 | ||||
-rw-r--r-- | macaroonbakery/bakery/_third_party.py | 57 | ||||
-rw-r--r-- | macaroonbakery/bakery/_versions.py | 9 |
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 |