From 9e4403035a9953c99117083e6373ae3c441a76b5 Mon Sep 17 00:00:00 2001 From: Colin Watson Date: Tue, 12 Dec 2017 15:20:49 +0000 Subject: Import py-macaroon-bakery_1.1.0.orig.tar.gz --- macaroonbakery/__init__.py | 137 --------- macaroonbakery/_utils/__init__.py | 162 +++++++++++ macaroonbakery/authorizer.py | 107 ------- macaroonbakery/bakery.py | 72 ----- macaroonbakery/bakery/__init__.py | 141 +++++++++ macaroonbakery/bakery/_authorizer.py | 106 +++++++ macaroonbakery/bakery/_bakery.py | 72 +++++ macaroonbakery/bakery/_checker.py | 417 +++++++++++++++++++++++++++ macaroonbakery/bakery/_codec.py | 301 +++++++++++++++++++ macaroonbakery/bakery/_discharge.py | 241 ++++++++++++++++ macaroonbakery/bakery/_error.py | 77 +++++ macaroonbakery/bakery/_identity.py | 126 ++++++++ macaroonbakery/bakery/_internal/__init__.py | 0 macaroonbakery/bakery/_internal/id.proto | 14 + macaroonbakery/bakery/_internal/id_pb2.py | 132 +++++++++ macaroonbakery/bakery/_keys.py | 100 +++++++ macaroonbakery/bakery/_macaroon.py | 430 ++++++++++++++++++++++++++++ macaroonbakery/bakery/_oven.py | 283 ++++++++++++++++++ macaroonbakery/bakery/_store.py | 77 +++++ macaroonbakery/bakery/_third_party.py | 57 ++++ macaroonbakery/bakery/_versions.py | 9 + macaroonbakery/checker.py | 411 -------------------------- macaroonbakery/checkers/__init__.py | 18 +- macaroonbakery/checkers/_auth_context.py | 58 ++++ macaroonbakery/checkers/_caveat.py | 128 +++++++++ macaroonbakery/checkers/_checkers.py | 246 ++++++++++++++++ macaroonbakery/checkers/_conditions.py | 17 ++ macaroonbakery/checkers/_declared.py | 84 ++++++ macaroonbakery/checkers/_namespace.py | 165 +++++++++++ macaroonbakery/checkers/_operation.py | 17 ++ macaroonbakery/checkers/_time.py | 67 +++++ macaroonbakery/checkers/_utils.py | 13 + macaroonbakery/checkers/auth_context.py | 58 ---- macaroonbakery/checkers/caveat.py | 125 -------- macaroonbakery/checkers/checkers.py | 243 ---------------- macaroonbakery/checkers/conditions.py | 17 -- macaroonbakery/checkers/declared.py | 82 ------ macaroonbakery/checkers/namespace.py | 165 ----------- macaroonbakery/checkers/operation.py | 17 -- macaroonbakery/checkers/time.py | 67 ----- macaroonbakery/checkers/utils.py | 13 - macaroonbakery/codec.py | 299 ------------------- macaroonbakery/discharge.py | 225 --------------- macaroonbakery/error.py | 77 ----- macaroonbakery/httpbakery/__init__.py | 12 +- macaroonbakery/httpbakery/_browser.py | 89 ++++++ macaroonbakery/httpbakery/_client.py | 408 ++++++++++++++++++++++++++ macaroonbakery/httpbakery/_discharge.py | 34 +++ macaroonbakery/httpbakery/_error.py | 202 +++++++++++++ macaroonbakery/httpbakery/_interactor.py | 70 +++++ macaroonbakery/httpbakery/_keyring.py | 60 ++++ macaroonbakery/httpbakery/agent/__init__.py | 8 +- macaroonbakery/httpbakery/agent/_agent.py | 184 ++++++++++++ macaroonbakery/httpbakery/agent/agent.py | 183 ------------ macaroonbakery/httpbakery/browser.py | 86 ------ macaroonbakery/httpbakery/client.py | 387 ------------------------- macaroonbakery/httpbakery/discharge.py | 33 --- macaroonbakery/httpbakery/error.py | 200 ------------- macaroonbakery/httpbakery/interactor.py | 73 ----- macaroonbakery/httpbakery/keyring.py | 60 ---- macaroonbakery/identity.py | 126 -------- macaroonbakery/internal/__init__.py | 0 macaroonbakery/internal/id.proto | 14 - macaroonbakery/internal/id_pb2.py | 132 --------- macaroonbakery/keys.py | 92 ------ macaroonbakery/macaroon.py | 414 -------------------------- macaroonbakery/oven.py | 268 ----------------- macaroonbakery/store.py | 77 ----- macaroonbakery/tests/common.py | 5 +- macaroonbakery/tests/test_agent.py | 171 ++++------- macaroonbakery/tests/test_authorizer.py | 2 +- macaroonbakery/tests/test_bakery.py | 87 ++++-- macaroonbakery/tests/test_checker.py | 34 ++- macaroonbakery/tests/test_checkers.py | 7 +- macaroonbakery/tests/test_client.py | 130 +++++++-- macaroonbakery/tests/test_codec.py | 7 +- macaroonbakery/tests/test_discharge.py | 5 +- macaroonbakery/tests/test_discharge_all.py | 5 +- macaroonbakery/tests/test_keyring.py | 16 +- macaroonbakery/tests/test_macaroon.py | 12 +- macaroonbakery/tests/test_oven.py | 8 +- macaroonbakery/tests/test_store.py | 2 +- macaroonbakery/tests/test_time.py | 19 +- macaroonbakery/tests/test_utils.py | 74 +++++ macaroonbakery/third_party.py | 57 ---- macaroonbakery/utils.py | 143 --------- macaroonbakery/versions.py | 9 - 87 files changed, 4983 insertions(+), 4695 deletions(-) create mode 100644 macaroonbakery/_utils/__init__.py delete mode 100644 macaroonbakery/authorizer.py delete mode 100644 macaroonbakery/bakery.py create mode 100644 macaroonbakery/bakery/__init__.py create mode 100644 macaroonbakery/bakery/_authorizer.py create mode 100644 macaroonbakery/bakery/_bakery.py create mode 100644 macaroonbakery/bakery/_checker.py create mode 100644 macaroonbakery/bakery/_codec.py create mode 100644 macaroonbakery/bakery/_discharge.py create mode 100644 macaroonbakery/bakery/_error.py create mode 100644 macaroonbakery/bakery/_identity.py create mode 100644 macaroonbakery/bakery/_internal/__init__.py create mode 100644 macaroonbakery/bakery/_internal/id.proto create mode 100644 macaroonbakery/bakery/_internal/id_pb2.py create mode 100644 macaroonbakery/bakery/_keys.py create mode 100644 macaroonbakery/bakery/_macaroon.py create mode 100644 macaroonbakery/bakery/_oven.py create mode 100644 macaroonbakery/bakery/_store.py create mode 100644 macaroonbakery/bakery/_third_party.py create mode 100644 macaroonbakery/bakery/_versions.py delete mode 100644 macaroonbakery/checker.py create mode 100644 macaroonbakery/checkers/_auth_context.py create mode 100644 macaroonbakery/checkers/_caveat.py create mode 100644 macaroonbakery/checkers/_checkers.py create mode 100644 macaroonbakery/checkers/_conditions.py create mode 100644 macaroonbakery/checkers/_declared.py create mode 100644 macaroonbakery/checkers/_namespace.py create mode 100644 macaroonbakery/checkers/_operation.py create mode 100644 macaroonbakery/checkers/_time.py create mode 100644 macaroonbakery/checkers/_utils.py delete mode 100644 macaroonbakery/checkers/auth_context.py delete mode 100644 macaroonbakery/checkers/caveat.py delete mode 100644 macaroonbakery/checkers/checkers.py delete mode 100644 macaroonbakery/checkers/conditions.py delete mode 100644 macaroonbakery/checkers/declared.py delete mode 100644 macaroonbakery/checkers/namespace.py delete mode 100644 macaroonbakery/checkers/operation.py delete mode 100644 macaroonbakery/checkers/time.py delete mode 100644 macaroonbakery/checkers/utils.py delete mode 100644 macaroonbakery/codec.py delete mode 100644 macaroonbakery/discharge.py delete mode 100644 macaroonbakery/error.py create mode 100644 macaroonbakery/httpbakery/_browser.py create mode 100644 macaroonbakery/httpbakery/_client.py create mode 100644 macaroonbakery/httpbakery/_discharge.py create mode 100644 macaroonbakery/httpbakery/_error.py create mode 100644 macaroonbakery/httpbakery/_interactor.py create mode 100644 macaroonbakery/httpbakery/_keyring.py create mode 100644 macaroonbakery/httpbakery/agent/_agent.py delete mode 100644 macaroonbakery/httpbakery/agent/agent.py delete mode 100644 macaroonbakery/httpbakery/browser.py delete mode 100644 macaroonbakery/httpbakery/client.py delete mode 100644 macaroonbakery/httpbakery/discharge.py delete mode 100644 macaroonbakery/httpbakery/error.py delete mode 100644 macaroonbakery/httpbakery/interactor.py delete mode 100644 macaroonbakery/httpbakery/keyring.py delete mode 100644 macaroonbakery/identity.py delete mode 100644 macaroonbakery/internal/__init__.py delete mode 100644 macaroonbakery/internal/id.proto delete mode 100644 macaroonbakery/internal/id_pb2.py delete mode 100644 macaroonbakery/keys.py delete mode 100644 macaroonbakery/macaroon.py delete mode 100644 macaroonbakery/oven.py delete mode 100644 macaroonbakery/store.py create mode 100644 macaroonbakery/tests/test_utils.py delete mode 100644 macaroonbakery/third_party.py delete mode 100644 macaroonbakery/utils.py delete mode 100644 macaroonbakery/versions.py (limited to 'macaroonbakery') diff --git a/macaroonbakery/__init__.py b/macaroonbakery/__init__.py index 6397a19..e69de29 100644 --- a/macaroonbakery/__init__.py +++ b/macaroonbakery/__init__.py @@ -1,137 +0,0 @@ -# Copyright 2017 Canonical Ltd. -# Licensed under the LGPLv3, see LICENCE file for details. - -from macaroonbakery.versions import ( - VERSION_0, - VERSION_1, - VERSION_2, - VERSION_3, - LATEST_VERSION, -) -from macaroonbakery.authorizer import ( - ACLAuthorizer, - Authorizer, - AuthorizerFunc, - ClosedAuthorizer, - EVERYONE, -) -from macaroonbakery.codec import ( - decode_caveat, - encode_caveat, - encode_uvarint, -) -from macaroonbakery.checker import ( - AuthChecker, - AuthInfo, - Checker, - LOGIN_OP, - Op, -) -from macaroonbakery.error import ( - AuthInitError, - CaveatNotRecognizedError, - DischargeRequiredError, - IdentityError, - PermissionDenied, - ThirdPartyCaveatCheckFailed, - ThirdPartyInfoNotFound, - VerificationError, -) -from macaroonbakery.identity import ( - ACLIdentity, - Identity, - IdentityClient, - NoIdentities, - SimpleIdentity, -) -from macaroonbakery.keys import ( - generate_key, - PrivateKey, - PublicKey, -) -from macaroonbakery.store import ( - MemoryOpsStore, - MemoryKeyStore, -) -from macaroonbakery.third_party import ( - ThirdPartyCaveatInfo, - ThirdPartyInfo, - legacy_namespace, -) -from macaroonbakery.macaroon import ( - Macaroon, - MacaroonJSONDecoder, - MacaroonJSONEncoder, - ThirdPartyLocator, - ThirdPartyStore, - macaroon_version, -) -from macaroonbakery.discharge import ( - ThirdPartyCaveatChecker, - discharge, - discharge_all, - local_third_party_caveat, -) -from macaroonbakery.oven import ( - Oven, - canonical_ops, -) -from macaroonbakery.bakery import Bakery -from macaroonbakery.utils import b64decode - -__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_version', -] diff --git a/macaroonbakery/_utils/__init__.py b/macaroonbakery/_utils/__init__.py new file mode 100644 index 0000000..f2779e0 --- /dev/null +++ b/macaroonbakery/_utils/__init__.py @@ -0,0 +1,162 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +import base64 +import binascii +import json +import webbrowser +from datetime import datetime + +import six +from pymacaroons import Macaroon +from pymacaroons.serializers import json_serializer + +import six.moves.http_cookiejar as http_cookiejar +from six.moves.urllib.parse import urlparse + + +def to_bytes(s): + '''Return s as a bytes type, using utf-8 encoding if necessary. + @param s string or bytes + @return bytes + ''' + if isinstance(s, six.binary_type): + return s + if isinstance(s, six.string_types): + return s.encode('utf-8') + raise TypeError('want string or bytes, got {}', type(s)) + + +def macaroon_from_dict(json_macaroon): + '''Return a pymacaroons.Macaroon object from the given + JSON-deserialized dict. + + @param JSON-encoded macaroon as dict + @return the deserialized macaroon object. + ''' + return Macaroon.deserialize(json.dumps(json_macaroon), + json_serializer.JsonSerializer()) + + +def macaroon_to_dict(macaroon): + '''Turn macaroon into JSON-serializable dict object + @param pymacaroons.Macaroon. + ''' + return json.loads(macaroon.serialize(json_serializer.JsonSerializer())) + + +def macaroon_to_json_string(macaroon): + '''Serialize macaroon object to a JSON-encoded string. + + @param macaroon object to be serialized. + @return a string serialization form of the macaroon. + ''' + return macaroon.serialize(json_serializer.JsonSerializer()) + + +def _add_base64_padding(b): + '''Add padding to base64 encoded bytes. + + pymacaroons does not give padded base64 bytes from serialization. + + @param bytes b to be padded. + @return a padded bytes. + ''' + return b + b'=' * (-len(b) % 4) + + +def _remove_base64_padding(b): + '''Remove padding from base64 encoded bytes. + + pymacaroons does not give padded base64 bytes from serialization. + + @param bytes b to be padded. + @return a padded bytes. + ''' + return b.rstrip(b'=') + + +def b64decode(s): + '''Base64 decodes a base64-encoded string in URL-safe + or normal format, with or without padding. + The argument may be string or bytes. + + @param s bytes decode + @return bytes decoded + @raises ValueError on failure + ''' + # add padding if necessary. + s = to_bytes(s) + if not s.endswith(b'='): + s = s + b'=' * (-len(s) % 4) + try: + if '_' or '-' in s: + return base64.urlsafe_b64decode(s) + else: + return base64.b64decode(s) + except (TypeError, binascii.Error) as e: + raise ValueError(str(e)) + + +def raw_urlsafe_b64encode(b): + '''Base64 encode using URL-safe encoding with padding removed. + + @param b bytes to decode + @return bytes decoded + ''' + b = to_bytes(b) + b = base64.urlsafe_b64encode(b) + b = b.rstrip(b'=') # strip padding + return b + + +def visit_page_with_browser(visit_url): + '''Open a browser so the user can validate its identity. + + @param visit_url: where to prove your identity. + ''' + webbrowser.open(visit_url, new=1) + print('Opening an authorization web page in your browser.') + print('If it does not open, please open this URL:\n', visit_url, '\n') + + +def cookie( + url, + name, + value, + expires=None): + '''Return a new Cookie using a slightly more + friendly API than that provided by six.moves.http_cookiejar + + @param name The cookie name {str} + @param value The cookie value {str} + @param url The URL path of the cookie {str} + @param expires The expiry time of the cookie {datetime}. If provided, + it must be a naive timestamp in UTC. + ''' + u = urlparse(url) + domain = u.hostname or u.netloc + port = str(u.port) if u.port is not None else None + secure = u.scheme == 'https' + if expires is not None: + if expires.tzinfo is not None: + raise ValueError('Cookie expiration must be a naive datetime') + expires = (expires - datetime(1970, 1, 1)).total_seconds() + return http_cookiejar.Cookie( + version=0, + name=name, + value=value, + port=port, + port_specified=port is not None, + domain=domain, + domain_specified=True, + domain_initial_dot=False, + path=u.path, + path_specified=True, + secure=secure, + expires=expires, + discard=False, + comment=None, + comment_url=None, + rest=None, + rfc2109=False, + ) diff --git a/macaroonbakery/authorizer.py b/macaroonbakery/authorizer.py deleted file mode 100644 index ae84104..0000000 --- a/macaroonbakery/authorizer.py +++ /dev/null @@ -1,107 +0,0 @@ -# Copyright 2017 Canonical Ltd. -# Licensed under the LGPLv3, see LICENCE file for details. -import abc - -import macaroonbakery as bakery - - -# 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, bakery.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.py b/macaroonbakery/bakery.py deleted file mode 100644 index 5d9d56a..0000000 --- a/macaroonbakery/bakery.py +++ /dev/null @@ -1,72 +0,0 @@ -# Copyright 2017 Canonical Ltd. -# Licensed under the LGPLv3, see LICENCE file for details. - -from macaroonbakery.checkers import checkers -from macaroonbakery.oven import Oven -from macaroonbakery.checker import Checker -from macaroonbakery.authorizer import ClosedAuthorizer - - -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/__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 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 + + where 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 diff --git a/macaroonbakery/checker.py b/macaroonbakery/checker.py deleted file mode 100644 index 568fd7c..0000000 --- a/macaroonbakery/checker.py +++ /dev/null @@ -1,411 +0,0 @@ -# Copyright 2017 Canonical Ltd. -# Licensed under the LGPLv3, see LICENCE file for details. -from collections import namedtuple -from threading import Lock - - -import pyrfc3339 - -import macaroonbakery as bakery -import macaroonbakery.checkers as checkers - - -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=bakery.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 = bakery.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 is not None and len(self._init_errors) > 0: - raise bakery.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 bakery.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 bakery.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 bakery.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 bakery.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 bakery.PermissionDenied(err) - raise bakery.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) - 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/checkers/__init__.py b/macaroonbakery/checkers/__init__.py index 25c6b7d..b3ea466 100644 --- a/macaroonbakery/checkers/__init__.py +++ b/macaroonbakery/checkers/__init__.py @@ -1,6 +1,6 @@ # Copyright 2017 Canonical Ltd. # Licensed under the LGPLv3, see LICENCE file for details. -from macaroonbakery.checkers.conditions import ( +from ._conditions import ( STD_NAMESPACE, COND_DECLARED, COND_TIME_BEFORE, @@ -9,7 +9,7 @@ from macaroonbakery.checkers.conditions import ( COND_DENY, COND_NEED_DECLARED, ) -from macaroonbakery.checkers.caveat import ( +from ._caveat import ( allow_caveat, deny_caveat, declared_caveat, @@ -17,35 +17,35 @@ from macaroonbakery.checkers.caveat import ( time_before_caveat, Caveat, ) -from macaroonbakery.checkers.declared import ( +from ._declared import ( context_with_declared, infer_declared, infer_declared_from_conditions, need_declared_caveat, ) -from macaroonbakery.checkers.operation import ( +from ._operation import ( context_with_operations, ) -from macaroonbakery.checkers.namespace import ( +from ._namespace import ( Namespace, deserialize_namespace ) -from macaroonbakery.checkers.time import ( +from ._time import ( context_with_clock, expiry_time, macaroons_expiry_time, ) -from macaroonbakery.checkers.checkers import ( +from ._checkers import ( Checker, CheckerInfo, RegisterError, ) -from macaroonbakery.checkers.auth_context import ( +from ._auth_context import ( AuthContext, ContextKey, ) -from macaroonbakery.checkers.utils import ( +from ._utils import ( condition_with_prefix, ) diff --git a/macaroonbakery/checkers/_auth_context.py b/macaroonbakery/checkers/_auth_context.py new file mode 100644 index 0000000..dceb015 --- /dev/null +++ b/macaroonbakery/checkers/_auth_context.py @@ -0,0 +1,58 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +import collections + + +class AuthContext(collections.Mapping): + ''' Holds a set of keys and values relevant to authorization. + + It is passed as an argument to authorization checkers, so that the checkers + can access information about the context of the authorization request. + It is immutable - values can only be added by copying the whole thing. + ''' + def __init__(self, somedict=None): + if somedict is None: + somedict = {} + self._dict = dict(somedict) + self._hash = None + + def with_value(self, key, val): + ''' Return a copy of the AuthContext object with the given key and + value added. + ''' + new_dict = dict(self._dict) + new_dict[key] = val + return AuthContext(new_dict) + + def __getitem__(self, key): + return self._dict[key] + + def __len__(self): + return len(self._dict) + + def __iter__(self): + return iter(self._dict) + + def __hash__(self): + if self._hash is None: + self._hash = hash(frozenset(self._dict.items())) + return self._hash + + def __eq__(self, other): + return self._dict == other._dict + + +class ContextKey(object): + '''Provides a unique key suitable for use as a key into AuthContext.''' + + def __init__(self, name): + '''Creates a context key using the given name. The name is + only for informational purposes. + ''' + self._name = name + + def __str__(self): + return '%s#%#x' % (self._name, id(self)) + + def __repr__(self): + return 'context_key(%r, %#x)' % (self._name, id(self)) diff --git a/macaroonbakery/checkers/_caveat.py b/macaroonbakery/checkers/_caveat.py new file mode 100644 index 0000000..5732f43 --- /dev/null +++ b/macaroonbakery/checkers/_caveat.py @@ -0,0 +1,128 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +import collections + +import pyrfc3339 +from ._conditions import ( + COND_ALLOW, + COND_DECLARED, + COND_DENY, + COND_ERROR, + COND_TIME_BEFORE, + STD_NAMESPACE, +) + + +class Caveat(collections.namedtuple('Caveat', 'condition location namespace')): + '''Represents a condition that must be true for a check to complete + successfully. + + If location is provided, the caveat must be discharged by + a third party at the given location (a URL string). + + The namespace parameter holds the namespace URI string of the + condition - if it is provided, it will be converted to a namespace prefix + before adding to the macaroon. + ''' + __slots__ = () + + def __new__(cls, condition, location=None, namespace=None): + return super(Caveat, cls).__new__(cls, condition, location, namespace) + + +def declared_caveat(key, value): + '''Returns a "declared" caveat asserting that the given key is + set to the given value. + + If a macaroon has exactly one first party caveat asserting the value of a + particular key, then infer_declared will be able to infer the value, and + then the check will allow the declared value if it has the value + specified here. + + If the key is empty or contains a space, it will return an error caveat. + ''' + if key.find(' ') >= 0 or key == '': + return error_caveat('invalid caveat \'declared\' key "{}"'.format(key)) + return _first_party(COND_DECLARED, key + ' ' + value) + + +def error_caveat(f): + '''Returns a caveat that will never be satisfied, holding f as the text of + the caveat. + + This should only be used for highly unusual conditions that are never + expected to happen in practice, such as a malformed key that is + conventionally passed as a constant. It's not a panic but you should + only use it in cases where a panic might possibly be appropriate. + + This mechanism means that caveats can be created without error + checking and a later systematic check at a higher level (in the + bakery package) can produce an error instead. + ''' + return _first_party(COND_ERROR, f) + + +def allow_caveat(ops): + ''' Returns a caveat that will deny attempts to use the macaroon to perform + any operation other than those listed. Operations must not contain a space. + ''' + if ops is None or len(ops) == 0: + return error_caveat('no operations allowed') + return _operation_caveat(COND_ALLOW, ops) + + +def deny_caveat(ops): + '''Returns a caveat that will deny attempts to use the macaroon to perform + any of the listed operations. Operations must not contain a space. + ''' + return _operation_caveat(COND_DENY, ops) + + +def _operation_caveat(cond, ops): + ''' Helper for allow_caveat and deny_caveat. + + It checks that all operation names are valid before creating the caveat. + ''' + for op in ops: + if op.find(' ') != -1: + return error_caveat('invalid operation name "{}"'.format(op)) + return _first_party(cond, ' '.join(ops)) + + +def time_before_caveat(t): + '''Return a caveat that specifies that the time that it is checked at + should be before t. + :param t is a a UTC date in - use datetime.utcnow, not datetime.now + ''' + + return _first_party(COND_TIME_BEFORE, + pyrfc3339.generate(t, accept_naive=True, + microseconds=True)) + + +def parse_caveat(cav): + ''' Parses a caveat into an identifier, identifying the checker that should + be used, and the argument to the checker (the rest of the string). + + The identifier is taken from all the characters before the first + space character. + :return two string, identifier and arg + ''' + if cav == '': + raise ValueError('empty caveat') + try: + i = cav.index(' ') + except ValueError: + return cav, '' + if i == 0: + raise ValueError('caveat starts with space character') + return cav[0:i], cav[i + 1:] + + +def _first_party(name, arg): + condition = name + if arg != '': + condition += ' ' + arg + + return Caveat(condition=condition, + namespace=STD_NAMESPACE) diff --git a/macaroonbakery/checkers/_checkers.py b/macaroonbakery/checkers/_checkers.py new file mode 100644 index 0000000..71cb56f --- /dev/null +++ b/macaroonbakery/checkers/_checkers.py @@ -0,0 +1,246 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +import abc +from collections import namedtuple +from datetime import datetime + +import pyrfc3339 +import pytz +from ._caveat import parse_caveat +from ._conditions import ( + COND_ALLOW, + COND_DECLARED, + COND_DENY, + COND_ERROR, + COND_TIME_BEFORE, + STD_NAMESPACE, +) +from ._declared import DECLARED_KEY +from ._namespace import Namespace +from ._operation import OP_KEY +from ._time import TIME_KEY +from ._utils import condition_with_prefix + + +class RegisterError(Exception): + '''Raised when a condition cannot be registered with a Checker.''' + pass + + +class FirstPartyCaveatChecker(object): + '''Used to check first party caveats for validity with respect to + information in the provided context. + + If the caveat kind was not recognised, the checker should return + ErrCaveatNotRecognized. + ''' + __metaclass__ = abc.ABCMeta + + @abc.abstractmethod + def check_first_party_caveat(self, ctx, caveat): + ''' Checks that the given caveat condition is valid with respect to + the given context information. + :param ctx: an Auth context + :param caveat a string + ''' + raise NotImplementedError('check_first_party_caveat method must be ' + 'defined in subclass') + + def namespace(self): + ''' Returns the namespace associated with the caveat checker. + ''' + raise NotImplementedError('namespace method must be ' + 'defined in subclass') + + +class Checker(FirstPartyCaveatChecker): + ''' Holds a set of checkers for first party caveats. + ''' + + def __init__(self, namespace=None, include_std_checkers=True): + if namespace is None: + namespace = Namespace() + self._namespace = namespace + self._checkers = {} + if include_std_checkers: + self.register_std() + + def check_first_party_caveat(self, ctx, cav): + ''' Checks the caveat against all registered caveat conditions. + :return: error message string if any or None + ''' + try: + cond, arg = parse_caveat(cav) + except ValueError as ex: + # If we can't parse it, perhaps it's in some other format, + # return a not-recognised error. + return 'cannot parse caveat "{}": {}'.format(cav, ex.args[0]) + checker = self._checkers.get(cond) + if checker is None: + return 'caveat "{}" not satisfied: caveat not recognized'.format( + cav) + err = checker.check(ctx, cond, arg) + if err is not None: + return 'caveat "{}" not satisfied: {}'.format(cav, err) + + def namespace(self): + ''' Returns the namespace associated with the Checker. + ''' + return self._namespace + + def info(self): + ''' Returns information on all the registered checkers. + + Sorted by namespace and then name + :returns a list of CheckerInfo + ''' + return sorted(self._checkers.values(), key=lambda x: (x.ns, x.name)) + + def register(self, cond, uri, check): + ''' Registers the given condition(string) in the given namespace + uri (string) to be checked with the given check function. + The check function checks a caveat by passing an auth context, a cond + parameter(string) that holds the caveat condition including any + namespace prefix and an arg parameter(string) that hold any additional + caveat argument text. It will return any error as string otherwise + None. + + It will raise a ValueError if the namespace is not registered or + if the condition has already been registered. + ''' + if check is None: + raise RegisterError( + 'no check function registered for namespace {} when ' + 'registering condition {}'.format(uri, cond)) + + prefix = self._namespace.resolve(uri) + if prefix is None: + raise RegisterError('no prefix registered for namespace {} when ' + 'registering condition {}'.format(uri, cond)) + + if prefix == '' and cond.find(':') >= 0: + raise RegisterError( + 'caveat condition {} in namespace {} contains a colon but its' + ' prefix is empty'.format(cond, uri)) + + full_cond = condition_with_prefix(prefix, cond) + info = self._checkers.get(full_cond) + if info is not None: + raise RegisterError( + 'checker for {} (namespace {}) already registered in ' + 'namespace {}'.format(full_cond, uri, info.ns)) + self._checkers[full_cond] = CheckerInfo( + check=check, + ns=uri, + name=cond, + prefix=prefix) + + def register_std(self): + ''' Registers all the standard checkers in the given checker. + + If not present already, the standard checkers schema (STD_NAMESPACE) is + added to the checker's namespace with an empty prefix. + ''' + self._namespace.register(STD_NAMESPACE, '') + for cond in _ALL_CHECKERS: + self.register(cond, STD_NAMESPACE, _ALL_CHECKERS[cond]) + + +class CheckerInfo(namedtuple('CheckInfo', 'prefix name ns check')): + '''CheckerInfo holds information on a registered checker. + ''' + __slots__ = () + + def __new__(cls, prefix, name, ns, check=None): + ''' + :param check holds the actual checker function which takes an auth + context and a condition and arg string as arguments. + :param prefix holds the prefix for the checker condition as string. + :param name holds the name of the checker condition as string. + :param ns holds the namespace URI for the checker's schema as + Namespace. + ''' + return super(CheckerInfo, cls).__new__(cls, prefix, name, ns, check) + + +def _check_time_before(ctx, cond, arg): + clock = ctx.get(TIME_KEY) + if clock is None: + now = pytz.UTC.localize(datetime.utcnow()) + else: + now = clock.utcnow() + + try: + if pyrfc3339.parse(arg) <= now: + return 'macaroon has expired' + except ValueError: + return 'cannot parse "{}" as RFC 3339'.format(arg) + return None + + +def _check_declared(ctx, cond, arg): + parts = arg.split(' ', 1) + if len(parts) != 2: + return 'declared caveat has no value' + attrs = ctx.get(DECLARED_KEY, {}) + val = attrs.get(parts[0]) + if val is None: + return 'got {}=null, expected "{}"'.format(parts[0], parts[1]) + + if val != parts[1]: + return 'got {}="{}", expected "{}"'.format(parts[0], val, parts[1]) + return None + + +def _check_error(ctx, cond, arg): + return 'bad caveat' + + +def _check_allow(ctx, cond, arg): + return _check_operations(ctx, True, arg) + + +def _check_deny(ctx, cond, arg): + return _check_operations(ctx, False, arg) + + +def _check_operations(ctx, need_ops, arg): + ''' Checks an allow or a deny caveat. The need_ops parameter specifies + whether we require all the operations in the caveat to be declared in + the context. + ''' + ctx_ops = ctx.get(OP_KEY, []) + if len(ctx_ops) == 0: + if need_ops: + f = arg.split() + if len(f) == 0: + return 'no operations allowed' + return '{} not allowed'.format(f[0]) + return None + + fields = arg.split() + for op in ctx_ops: + err = _check_op(op, need_ops, fields) + if err is not None: + return err + return None + + +def _check_op(ctx_op, need_op, fields): + found = False + for op in fields: + if op == ctx_op: + found = True + break + if found != need_op: + return '{} not allowed'.format(ctx_op) + return None + + +_ALL_CHECKERS = { + COND_TIME_BEFORE: _check_time_before, + COND_DECLARED: _check_declared, + COND_ERROR: _check_error, + COND_ALLOW: _check_allow, + COND_DENY: _check_deny, +} diff --git a/macaroonbakery/checkers/_conditions.py b/macaroonbakery/checkers/_conditions.py new file mode 100644 index 0000000..74e863e --- /dev/null +++ b/macaroonbakery/checkers/_conditions.py @@ -0,0 +1,17 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. + +# StdNamespace holds the URI of the standard checkers schema. +STD_NAMESPACE = 'std' + +# Constants for all the standard caveat conditions. +# First and third party caveat conditions are both defined here, +# even though notionally they exist in separate name spaces. +COND_DECLARED = 'declared' +COND_TIME_BEFORE = 'time-before' +COND_ERROR = 'error' +COND_ALLOW = 'allow' +COND_DENY = 'deny' + + +COND_NEED_DECLARED = 'need-declared' diff --git a/macaroonbakery/checkers/_declared.py b/macaroonbakery/checkers/_declared.py new file mode 100644 index 0000000..ae4f95b --- /dev/null +++ b/macaroonbakery/checkers/_declared.py @@ -0,0 +1,84 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +from ._auth_context import ContextKey +from ._caveat import Caveat, error_caveat, parse_caveat +from ._conditions import ( + COND_DECLARED, + COND_NEED_DECLARED, + STD_NAMESPACE, +) +from ._namespace import Namespace + +DECLARED_KEY = ContextKey('declared-key') + + +def infer_declared(ms, namespace=None): + '''Retrieves any declared information from the given macaroons and returns + it as a key-value map. + Information is declared with a first party caveat as created by + declared_caveat. + + If there are two caveats that declare the same key with different values, + the information is omitted from the map. When the caveats are later + checked, this will cause the check to fail. + namespace is the Namespace used to retrieve the prefix associated to the + uri, if None it will use the STD_NAMESPACE only. + ''' + conditions = [] + for m in ms: + for cav in m.caveats: + if cav.location is None or cav.location == '': + conditions.append(cav.caveat_id_bytes.decode('utf-8')) + return infer_declared_from_conditions(conditions, namespace) + + +def infer_declared_from_conditions(conds, namespace=None): + ''' like infer_declared except that it is passed a set of first party + caveat conditions as a list of string rather than a set of macaroons. + ''' + conflicts = [] + # If we can't resolve that standard namespace, then we'll look for + # just bare "declared" caveats which will work OK for legacy + # macaroons with no namespace. + if namespace is None: + namespace = Namespace() + prefix = namespace.resolve(STD_NAMESPACE) + if prefix is None: + prefix = '' + declared_cond = prefix + COND_DECLARED + + info = {} + for cond in conds: + try: + name, rest = parse_caveat(cond) + except ValueError: + name, rest = '', '' + if name != declared_cond: + continue + parts = rest.split(' ', 1) + if len(parts) != 2: + continue + key, val = parts[0], parts[1] + old_val = info.get(key) + if old_val is not None and old_val != val: + conflicts.append(key) + continue + info[key] = val + for key in set(conflicts): + del info[key] + return info + + +def context_with_declared(ctx, declared): + ''' Returns a context with attached declared information, + as returned from infer_declared. + ''' + return ctx.with_value(DECLARED_KEY, declared) + + +def need_declared_caveat(cav, keys): + if cav.location == '': + return error_caveat('need-declared caveat is not third-party') + return Caveat(location=cav.location, + condition=(COND_NEED_DECLARED + ' ' + ','.join(keys) + + ' ' + cav.condition)) diff --git a/macaroonbakery/checkers/_namespace.py b/macaroonbakery/checkers/_namespace.py new file mode 100644 index 0000000..6c3b1e3 --- /dev/null +++ b/macaroonbakery/checkers/_namespace.py @@ -0,0 +1,165 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +import collections + +from ._caveat import error_caveat +from ._utils import condition_with_prefix + + +class Namespace: + '''Holds maps from schema URIs to prefixes. + + prefixes that are used to encode them in first party + caveats. Several different URIs may map to the same + prefix - this is usual when several different backwardly + compatible schema versions are registered. + ''' + + def __init__(self, uri_to_prefix=None): + self._uri_to_prefix = {} + if uri_to_prefix is not None: + for k in uri_to_prefix: + self.register(k, uri_to_prefix[k]) + + def __str__(self): + '''Returns the namespace representation as returned by serialize + :return: str + ''' + return self.serialize_text().decode('utf-8') + + def __eq__(self, other): + return self._uri_to_prefix == other._uri_to_prefix + + def serialize_text(self): + '''Returns a serialized form of the Namepace. + + All the elements in the namespace are sorted by + URI, joined to the associated prefix with a colon and + separated with spaces. + :return: bytes + ''' + if self._uri_to_prefix is None or len(self._uri_to_prefix) == 0: + return b'' + od = collections.OrderedDict(sorted(self._uri_to_prefix.items())) + data = [] + for uri in od: + data.append(uri + ':' + od[uri]) + return ' '.join(data).encode('utf-8') + + def register(self, uri, prefix): + '''Registers the given URI and associates it with the given prefix. + + If the URI has already been registered, this is a no-op. + + :param uri: string + :param prefix: string + ''' + if not is_valid_schema_uri(uri): + raise KeyError( + 'cannot register invalid URI {} (prefix {})'.format( + uri, prefix)) + if not is_valid_prefix(prefix): + raise ValueError( + 'cannot register invalid prefix %q for URI %q'.format( + prefix, uri)) + if self._uri_to_prefix.get(uri) is None: + self._uri_to_prefix[uri] = prefix + + def resolve(self, uri): + ''' Returns the prefix associated to the uri. + + returns None if not found. + :param uri: string + :return: string + ''' + return self._uri_to_prefix.get(uri) + + def resolve_caveat(self, cav): + ''' Resolves the given caveat(string) by using resolve to map from its + schema namespace to the appropriate prefix. + If there is no registered prefix for the namespace, it returns an error + caveat. + If cav.namespace is empty or cav.location is non-empty, it returns cav + unchanged. + + It does not mutate ns and may be called concurrently with other + non-mutating Namespace methods. + :return: Caveat object + ''' + # TODO: If a namespace isn't registered, try to resolve it by + # resolving it to the latest compatible version that is + # registered. + if cav.namespace == '' or cav.location != '': + return cav + + prefix = self.resolve(cav.namespace) + if prefix is None: + err_cav = error_caveat( + 'caveat {} in unregistered namespace {}'.format( + cav.condition, cav.namespace)) + if err_cav.namespace != cav.namespace: + prefix = self.resolve(err_cav.namespace) + if prefix is None: + prefix = '' + cav = err_cav + if prefix != '': + cav.condition = condition_with_prefix(prefix, cav.condition) + cav.namespace = '' + return cav + + +def is_valid_schema_uri(uri): + '''Reports if uri is suitable for use as a namespace schema URI. + + It must be non-empty and it must not contain white space. + + :param uri string + :return bool + ''' + if len(uri) <= 0: + return False + return uri.find(' ') == -1 + + +def is_valid_prefix(prefix): + '''Reports if prefix is valid. + + It must not contain white space or semi-colon. + :param prefix string + :return bool + ''' + return prefix.find(' ') == -1 and prefix.find(':') == -1 + + +def deserialize_namespace(data): + ''' Deserialize a Namespace object. + + :param data: bytes or str + :return: namespace + ''' + if isinstance(data, bytes): + data = data.decode('utf-8') + kvs = data.split() + uri_to_prefix = {} + for kv in kvs: + i = kv.rfind(':') + if i == -1: + raise ValueError('no colon in namespace ' + 'field {}'.format(repr(kv))) + uri, prefix = kv[0:i], kv[i + 1:] + if not is_valid_schema_uri(uri): + # Currently this can't happen because the only invalid URIs + # are those which contain a space + raise ValueError( + 'invalid URI {} in namespace ' + 'field {}'.format(repr(uri), repr(kv))) + if not is_valid_prefix(prefix): + raise ValueError( + 'invalid prefix {} in namespace field' + ' {}'.format(repr(prefix), repr(kv))) + if uri in uri_to_prefix: + raise ValueError( + 'duplicate URI {} in ' + 'namespace {}'.format(repr(uri), repr(data))) + uri_to_prefix[uri] = prefix + return Namespace(uri_to_prefix) diff --git a/macaroonbakery/checkers/_operation.py b/macaroonbakery/checkers/_operation.py new file mode 100644 index 0000000..56b267a --- /dev/null +++ b/macaroonbakery/checkers/_operation.py @@ -0,0 +1,17 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +from ._auth_context import ContextKey + +OP_KEY = ContextKey('op-key') + + +def context_with_operations(ctx, ops): + ''' Returns a context(AuthContext) which is associated with all the given + operations (list of string). It will be based on the auth context + passed in as ctx. + + An allow caveat will succeed only if one of the allowed operations is in + ops; a deny caveat will succeed only if none of the denied operations are + in ops. + ''' + return ctx.with_value(OP_KEY, ops) diff --git a/macaroonbakery/checkers/_time.py b/macaroonbakery/checkers/_time.py new file mode 100644 index 0000000..2ae1d89 --- /dev/null +++ b/macaroonbakery/checkers/_time.py @@ -0,0 +1,67 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. + +import pyrfc3339 +from ._auth_context import ContextKey +from ._caveat import parse_caveat +from ._conditions import COND_TIME_BEFORE, STD_NAMESPACE +from ._utils import condition_with_prefix + +TIME_KEY = ContextKey('time-key') + + +def context_with_clock(ctx, clock): + ''' Returns a copy of ctx with a key added that associates it with the + given clock implementation, which will be used by the time-before checker + to determine the current time. + The clock should have a utcnow method that returns the current time + as a datetime value in UTC. + ''' + if clock is None: + return ctx + return ctx.with_value(TIME_KEY, clock) + + +def macaroons_expiry_time(ns, ms): + ''' Returns the minimum time of any time-before caveats found in the given + macaroons or None if no such caveats were found. + :param ns: a Namespace, used to resolve caveats. + :param ms: a list of pymacaroons.Macaroon + :return: datetime.DateTime or None. + ''' + t = None + for m in ms: + et = expiry_time(ns, m.caveats) + if et is not None and (t is None or et < t): + t = et + return t + + +def expiry_time(ns, cavs): + ''' Returns the minimum time of any time-before caveats found + in the given list or None if no such caveats were found. + + The ns parameter is + :param ns: used to determine the standard namespace prefix - if + the standard namespace is not found, the empty prefix is assumed. + :param cavs: a list of pymacaroons.Caveat + :return: datetime.DateTime or None. + ''' + prefix = ns.resolve(STD_NAMESPACE) + time_before_cond = condition_with_prefix( + prefix, COND_TIME_BEFORE) + t = None + for cav in cavs: + if not cav.first_party(): + continue + cav = cav.caveat_id_bytes.decode('utf-8') + name, rest = parse_caveat(cav) + if name != time_before_cond: + continue + try: + et = pyrfc3339.parse(rest, utc=True).replace(tzinfo=None) + if t is None or et < t: + t = et + except ValueError: + continue + return t diff --git a/macaroonbakery/checkers/_utils.py b/macaroonbakery/checkers/_utils.py new file mode 100644 index 0000000..925e8c7 --- /dev/null +++ b/macaroonbakery/checkers/_utils.py @@ -0,0 +1,13 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. + + +def condition_with_prefix(prefix, condition): + '''Returns the given string prefixed by the given prefix. + + If the prefix is non-empty, a colon is used to separate them. + ''' + if prefix == '' or prefix is None: + return condition + + return prefix + ':' + condition diff --git a/macaroonbakery/checkers/auth_context.py b/macaroonbakery/checkers/auth_context.py deleted file mode 100644 index dceb015..0000000 --- a/macaroonbakery/checkers/auth_context.py +++ /dev/null @@ -1,58 +0,0 @@ -# Copyright 2017 Canonical Ltd. -# Licensed under the LGPLv3, see LICENCE file for details. -import collections - - -class AuthContext(collections.Mapping): - ''' Holds a set of keys and values relevant to authorization. - - It is passed as an argument to authorization checkers, so that the checkers - can access information about the context of the authorization request. - It is immutable - values can only be added by copying the whole thing. - ''' - def __init__(self, somedict=None): - if somedict is None: - somedict = {} - self._dict = dict(somedict) - self._hash = None - - def with_value(self, key, val): - ''' Return a copy of the AuthContext object with the given key and - value added. - ''' - new_dict = dict(self._dict) - new_dict[key] = val - return AuthContext(new_dict) - - def __getitem__(self, key): - return self._dict[key] - - def __len__(self): - return len(self._dict) - - def __iter__(self): - return iter(self._dict) - - def __hash__(self): - if self._hash is None: - self._hash = hash(frozenset(self._dict.items())) - return self._hash - - def __eq__(self, other): - return self._dict == other._dict - - -class ContextKey(object): - '''Provides a unique key suitable for use as a key into AuthContext.''' - - def __init__(self, name): - '''Creates a context key using the given name. The name is - only for informational purposes. - ''' - self._name = name - - def __str__(self): - return '%s#%#x' % (self._name, id(self)) - - def __repr__(self): - return 'context_key(%r, %#x)' % (self._name, id(self)) diff --git a/macaroonbakery/checkers/caveat.py b/macaroonbakery/checkers/caveat.py deleted file mode 100644 index a1e564e..0000000 --- a/macaroonbakery/checkers/caveat.py +++ /dev/null @@ -1,125 +0,0 @@ -# Copyright 2017 Canonical Ltd. -# Licensed under the LGPLv3, see LICENCE file for details. -import collections - -import pyrfc3339 - -from macaroonbakery.checkers.conditions import ( - STD_NAMESPACE, COND_TIME_BEFORE, COND_ERROR, COND_DENY, COND_ALLOW, - COND_DECLARED -) - - -class Caveat(collections.namedtuple('Caveat', 'condition location namespace')): - '''Represents a condition that must be true for a check to complete - successfully. - - If location is provided, the caveat must be discharged by - a third party at the given location (a URL string). - - The namespace parameter holds the namespace URI string of the - condition - if it is provided, it will be converted to a namespace prefix - before adding to the macaroon. - ''' - __slots__ = () - - def __new__(cls, condition, location=None, namespace=None): - return super(Caveat, cls).__new__(cls, condition, location, namespace) - - -def declared_caveat(key, value): - '''Returns a "declared" caveat asserting that the given key is - set to the given value. - - If a macaroon has exactly one first party caveat asserting the value of a - particular key, then infer_declared will be able to infer the value, and - then the check will allow the declared value if it has the value - specified here. - - If the key is empty or contains a space, it will return an error caveat. - ''' - if key.find(' ') >= 0 or key == '': - return error_caveat('invalid caveat \'declared\' key "{}"'.format(key)) - return _first_party(COND_DECLARED, key + ' ' + value) - - -def error_caveat(f): - '''Returns a caveat that will never be satisfied, holding f as the text of - the caveat. - - This should only be used for highly unusual conditions that are never - expected to happen in practice, such as a malformed key that is - conventionally passed as a constant. It's not a panic but you should - only use it in cases where a panic might possibly be appropriate. - - This mechanism means that caveats can be created without error - checking and a later systematic check at a higher level (in the - bakery package) can produce an error instead. - ''' - return _first_party(COND_ERROR, f) - - -def allow_caveat(ops): - ''' Returns a caveat that will deny attempts to use the macaroon to perform - any operation other than those listed. Operations must not contain a space. - ''' - if ops is None or len(ops) == 0: - return error_caveat('no operations allowed') - return _operation_caveat(COND_ALLOW, ops) - - -def deny_caveat(ops): - '''Returns a caveat that will deny attempts to use the macaroon to perform - any of the listed operations. Operations must not contain a space. - ''' - return _operation_caveat(COND_DENY, ops) - - -def _operation_caveat(cond, ops): - ''' Helper for allow_caveat and deny_caveat. - - It checks that all operation names are valid before creating the caveat. - ''' - for op in ops: - if op.find(' ') != -1: - return error_caveat('invalid operation name "{}"'.format(op)) - return _first_party(cond, ' '.join(ops)) - - -def time_before_caveat(t): - '''Return a caveat that specifies that the time that it is checked at - should be before t. - :param t is a a UTC date in - use datetime.utcnow, not datetime.now - ''' - - return _first_party(COND_TIME_BEFORE, - pyrfc3339.generate(t, accept_naive=True, - microseconds=True)) - - -def parse_caveat(cav): - ''' Parses a caveat into an identifier, identifying the checker that should - be used, and the argument to the checker (the rest of the string). - - The identifier is taken from all the characters before the first - space character. - :return two string, identifier and arg - ''' - if cav == '': - raise ValueError('empty caveat') - try: - i = cav.index(' ') - except ValueError: - return cav, '' - if i == 0: - raise ValueError('caveat starts with space character') - return cav[0:i], cav[i + 1:] - - -def _first_party(name, arg): - condition = name - if arg != '': - condition += ' ' + arg - - return Caveat(condition=condition, - namespace=STD_NAMESPACE) diff --git a/macaroonbakery/checkers/checkers.py b/macaroonbakery/checkers/checkers.py deleted file mode 100644 index 776b50b..0000000 --- a/macaroonbakery/checkers/checkers.py +++ /dev/null @@ -1,243 +0,0 @@ -# Copyright 2017 Canonical Ltd. -# Licensed under the LGPLv3, see LICENCE file for details. -import abc -from collections import namedtuple -from datetime import datetime - -import pyrfc3339 -import pytz - -from macaroonbakery.checkers.declared import DECLARED_KEY -from macaroonbakery.checkers.time import TIME_KEY -from macaroonbakery.checkers.operation import OP_KEY -from macaroonbakery.checkers.namespace import Namespace -from macaroonbakery.checkers.caveat import parse_caveat -from macaroonbakery.checkers.conditions import ( - STD_NAMESPACE, COND_DECLARED, COND_ALLOW, COND_DENY, COND_ERROR, - COND_TIME_BEFORE -) -from macaroonbakery.checkers.utils import condition_with_prefix - - -class RegisterError(Exception): - '''Raised when a condition cannot be registered with a Checker.''' - pass - - -class FirstPartyCaveatChecker(object): - '''Used to check first party caveats for validity with respect to - information in the provided context. - - If the caveat kind was not recognised, the checker should return - ErrCaveatNotRecognized. - ''' - __metaclass__ = abc.ABCMeta - - @abc.abstractmethod - def check_first_party_caveat(self, ctx, caveat): - ''' Checks that the given caveat condition is valid with respect to - the given context information. - :param ctx: an Auth context - :param caveat a string - ''' - raise NotImplementedError('check_first_party_caveat method must be ' - 'defined in subclass') - - def namespace(self): - ''' Returns the namespace associated with the caveat checker. - ''' - raise NotImplementedError('namespace method must be ' - 'defined in subclass') - - -class Checker(FirstPartyCaveatChecker): - ''' Holds a set of checkers for first party caveats. - ''' - - def __init__(self, namespace=None, include_std_checkers=True): - if namespace is None: - namespace = Namespace() - self._namespace = namespace - self._checkers = {} - if include_std_checkers: - self.register_std() - - def check_first_party_caveat(self, ctx, cav): - ''' Checks the caveat against all registered caveat conditions. - :return: error message string if any or None - ''' - try: - cond, arg = parse_caveat(cav) - except ValueError as ex: - # If we can't parse it, perhaps it's in some other format, - # return a not-recognised error. - return 'cannot parse caveat "{}": {}'.format(cav, ex.args[0]) - checker = self._checkers.get(cond) - if checker is None: - return 'caveat "{}" not satisfied: caveat not recognized'.format( - cav) - err = checker.check(ctx, cond, arg) - if err is not None: - return 'caveat "{}" not satisfied: {}'.format(cav, err) - - def namespace(self): - ''' Returns the namespace associated with the Checker. - ''' - return self._namespace - - def info(self): - ''' Returns information on all the registered checkers. - - Sorted by namespace and then name - :returns a list of CheckerInfo - ''' - return sorted(self._checkers.values(), key=lambda x: (x.ns, x.name)) - - def register(self, cond, uri, check): - ''' Registers the given condition(string) in the given namespace - uri (string) to be checked with the given check function. - The check function checks a caveat by passing an auth context, a cond - parameter(string) that holds the caveat condition including any - namespace prefix and an arg parameter(string) that hold any additional - caveat argument text. It will return any error as string otherwise - None. - - It will raise a ValueError if the namespace is not registered or - if the condition has already been registered. - ''' - if check is None: - raise RegisterError( - 'no check function registered for namespace {} when ' - 'registering condition {}'.format(uri, cond)) - - prefix = self._namespace.resolve(uri) - if prefix is None: - raise RegisterError('no prefix registered for namespace {} when ' - 'registering condition {}'.format(uri, cond)) - - if prefix == '' and cond.find(':') >= 0: - raise RegisterError( - 'caveat condition {} in namespace {} contains a colon but its' - ' prefix is empty'.format(cond, uri)) - - full_cond = condition_with_prefix(prefix, cond) - info = self._checkers.get(full_cond) - if info is not None: - raise RegisterError( - 'checker for {} (namespace {}) already registered in ' - 'namespace {}'.format(full_cond, uri, info.ns)) - self._checkers[full_cond] = CheckerInfo( - check=check, - ns=uri, - name=cond, - prefix=prefix) - - def register_std(self): - ''' Registers all the standard checkers in the given checker. - - If not present already, the standard checkers schema (STD_NAMESPACE) is - added to the checker's namespace with an empty prefix. - ''' - self._namespace.register(STD_NAMESPACE, '') - for cond in _ALL_CHECKERS: - self.register(cond, STD_NAMESPACE, _ALL_CHECKERS[cond]) - - -class CheckerInfo(namedtuple('CheckInfo', 'prefix name ns check')): - '''CheckerInfo holds information on a registered checker. - ''' - __slots__ = () - - def __new__(cls, prefix, name, ns, check=None): - ''' - :param check holds the actual checker function which takes an auth - context and a condition and arg string as arguments. - :param prefix holds the prefix for the checker condition as string. - :param name holds the name of the checker condition as string. - :param ns holds the namespace URI for the checker's schema as - Namespace. - ''' - return super(CheckerInfo, cls).__new__(cls, prefix, name, ns, check) - - -def _check_time_before(ctx, cond, arg): - clock = ctx.get(TIME_KEY) - if clock is None: - now = pytz.UTC.localize(datetime.utcnow()) - else: - now = clock.utcnow() - - try: - if pyrfc3339.parse(arg) <= now: - return 'macaroon has expired' - except ValueError: - return 'cannot parse "{}" as RFC 3339'.format(arg) - return None - - -def _check_declared(ctx, cond, arg): - parts = arg.split(' ', 1) - if len(parts) != 2: - return 'declared caveat has no value' - attrs = ctx.get(DECLARED_KEY, {}) - val = attrs.get(parts[0]) - if val is None: - return 'got {}=null, expected "{}"'.format(parts[0], parts[1]) - - if val != parts[1]: - return 'got {}="{}", expected "{}"'.format(parts[0], val, parts[1]) - return None - - -def _check_error(ctx, cond, arg): - return 'bad caveat' - - -def _check_allow(ctx, cond, arg): - return _check_operations(ctx, True, arg) - - -def _check_deny(ctx, cond, arg): - return _check_operations(ctx, False, arg) - - -def _check_operations(ctx, need_ops, arg): - ''' Checks an allow or a deny caveat. The need_ops parameter specifies - whether we require all the operations in the caveat to be declared in - the context. - ''' - ctx_ops = ctx.get(OP_KEY, []) - if len(ctx_ops) == 0: - if need_ops: - f = arg.split() - if len(f) == 0: - return 'no operations allowed' - return '{} not allowed'.format(f[0]) - return None - - fields = arg.split() - for op in ctx_ops: - err = _check_op(op, need_ops, fields) - if err is not None: - return err - return None - - -def _check_op(ctx_op, need_op, fields): - found = False - for op in fields: - if op == ctx_op: - found = True - break - if found != need_op: - return '{} not allowed'.format(ctx_op) - return None - - -_ALL_CHECKERS = { - COND_TIME_BEFORE: _check_time_before, - COND_DECLARED: _check_declared, - COND_ERROR: _check_error, - COND_ALLOW: _check_allow, - COND_DENY: _check_deny, -} diff --git a/macaroonbakery/checkers/conditions.py b/macaroonbakery/checkers/conditions.py deleted file mode 100644 index 74e863e..0000000 --- a/macaroonbakery/checkers/conditions.py +++ /dev/null @@ -1,17 +0,0 @@ -# Copyright 2017 Canonical Ltd. -# Licensed under the LGPLv3, see LICENCE file for details. - -# StdNamespace holds the URI of the standard checkers schema. -STD_NAMESPACE = 'std' - -# Constants for all the standard caveat conditions. -# First and third party caveat conditions are both defined here, -# even though notionally they exist in separate name spaces. -COND_DECLARED = 'declared' -COND_TIME_BEFORE = 'time-before' -COND_ERROR = 'error' -COND_ALLOW = 'allow' -COND_DENY = 'deny' - - -COND_NEED_DECLARED = 'need-declared' diff --git a/macaroonbakery/checkers/declared.py b/macaroonbakery/checkers/declared.py deleted file mode 100644 index 78a6181..0000000 --- a/macaroonbakery/checkers/declared.py +++ /dev/null @@ -1,82 +0,0 @@ -# Copyright 2017 Canonical Ltd. -# Licensed under the LGPLv3, see LICENCE file for details. -from macaroonbakery.checkers.namespace import Namespace -from macaroonbakery.checkers.caveat import parse_caveat, Caveat, error_caveat -from macaroonbakery.checkers.conditions import ( - COND_DECLARED, COND_NEED_DECLARED, STD_NAMESPACE -) -from macaroonbakery.checkers.auth_context import ContextKey - -DECLARED_KEY = ContextKey('declared-key') - - -def infer_declared(ms, namespace=None): - '''Retrieves any declared information from the given macaroons and returns - it as a key-value map. - Information is declared with a first party caveat as created by - declared_caveat. - - If there are two caveats that declare the same key with different values, - the information is omitted from the map. When the caveats are later - checked, this will cause the check to fail. - namespace is the Namespace used to retrieve the prefix associated to the - uri, if None it will use the STD_NAMESPACE only. - ''' - conditions = [] - for m in ms: - for cav in m.caveats: - if cav.location is None or cav.location == '': - conditions.append(cav.caveat_id_bytes.decode('utf-8')) - return infer_declared_from_conditions(conditions, namespace) - - -def infer_declared_from_conditions(conds, namespace=None): - ''' like infer_declared except that it is passed a set of first party - caveat conditions as a list of string rather than a set of macaroons. - ''' - conflicts = [] - # If we can't resolve that standard namespace, then we'll look for - # just bare "declared" caveats which will work OK for legacy - # macaroons with no namespace. - if namespace is None: - namespace = Namespace() - prefix = namespace.resolve(STD_NAMESPACE) - if prefix is None: - prefix = '' - declared_cond = prefix + COND_DECLARED - - info = {} - for cond in conds: - try: - name, rest = parse_caveat(cond) - except ValueError: - name, rest = '', '' - if name != declared_cond: - continue - parts = rest.split(' ', 1) - if len(parts) != 2: - continue - key, val = parts[0], parts[1] - old_val = info.get(key) - if old_val is not None and old_val != val: - conflicts.append(key) - continue - info[key] = val - for key in set(conflicts): - del info[key] - return info - - -def context_with_declared(ctx, declared): - ''' Returns a context with attached declared information, - as returned from infer_declared. - ''' - return ctx.with_value(DECLARED_KEY, declared) - - -def need_declared_caveat(cav, keys): - if cav.location == '': - return error_caveat('need-declared caveat is not third-party') - return Caveat(location=cav.location, - condition=(COND_NEED_DECLARED + ' ' + ','.join(keys) - + ' ' + cav.condition)) diff --git a/macaroonbakery/checkers/namespace.py b/macaroonbakery/checkers/namespace.py deleted file mode 100644 index 31e8801..0000000 --- a/macaroonbakery/checkers/namespace.py +++ /dev/null @@ -1,165 +0,0 @@ -# Copyright 2017 Canonical Ltd. -# Licensed under the LGPLv3, see LICENCE file for details. -import collections - -from macaroonbakery.checkers.utils import condition_with_prefix -from macaroonbakery.checkers.caveat import error_caveat - - -class Namespace: - '''Holds maps from schema URIs to prefixes. - - prefixes that are used to encode them in first party - caveats. Several different URIs may map to the same - prefix - this is usual when several different backwardly - compatible schema versions are registered. - ''' - - def __init__(self, uri_to_prefix=None): - self._uri_to_prefix = {} - if uri_to_prefix is not None: - for k in uri_to_prefix: - self.register(k, uri_to_prefix[k]) - - def __str__(self): - '''Returns the namespace representation as returned by serialize - :return: str - ''' - return self.serialize_text().decode('utf-8') - - def __eq__(self, other): - return self._uri_to_prefix == other._uri_to_prefix - - def serialize_text(self): - '''Returns a serialized form of the Namepace. - - All the elements in the namespace are sorted by - URI, joined to the associated prefix with a colon and - separated with spaces. - :return: bytes - ''' - if self._uri_to_prefix is None or len(self._uri_to_prefix) == 0: - return b'' - od = collections.OrderedDict(sorted(self._uri_to_prefix.items())) - data = [] - for uri in od: - data.append(uri + ':' + od[uri]) - return ' '.join(data).encode('utf-8') - - def register(self, uri, prefix): - '''Registers the given URI and associates it with the given prefix. - - If the URI has already been registered, this is a no-op. - - :param uri: string - :param prefix: string - ''' - if not is_valid_schema_uri(uri): - raise KeyError( - 'cannot register invalid URI {} (prefix {})'.format( - uri, prefix)) - if not is_valid_prefix(prefix): - raise ValueError( - 'cannot register invalid prefix %q for URI %q'.format( - prefix, uri)) - if self._uri_to_prefix.get(uri) is None: - self._uri_to_prefix[uri] = prefix - - def resolve(self, uri): - ''' Returns the prefix associated to the uri. - - returns None if not found. - :param uri: string - :return: string - ''' - return self._uri_to_prefix.get(uri) - - def resolve_caveat(self, cav): - ''' Resolves the given caveat(string) by using resolve to map from its - schema namespace to the appropriate prefix. - If there is no registered prefix for the namespace, it returns an error - caveat. - If cav.namespace is empty or cav.location is non-empty, it returns cav - unchanged. - - It does not mutate ns and may be called concurrently with other - non-mutating Namespace methods. - :return: Caveat object - ''' - # TODO: If a namespace isn't registered, try to resolve it by - # resolving it to the latest compatible version that is - # registered. - if cav.namespace == '' or cav.location != '': - return cav - - prefix = self.resolve(cav.namespace) - if prefix is None: - err_cav = error_caveat( - 'caveat {} in unregistered namespace {}'.format( - cav.condition, cav.namespace)) - if err_cav.namespace != cav.namespace: - prefix = self.resolve(err_cav.namespace) - if prefix is None: - prefix = '' - cav = err_cav - if prefix != '': - cav.condition = condition_with_prefix(prefix, cav.condition) - cav.namespace = '' - return cav - - -def is_valid_schema_uri(uri): - '''Reports if uri is suitable for use as a namespace schema URI. - - It must be non-empty and it must not contain white space. - - :param uri string - :return bool - ''' - if len(uri) <= 0: - return False - return uri.find(' ') == -1 - - -def is_valid_prefix(prefix): - '''Reports if prefix is valid. - - It must not contain white space or semi-colon. - :param prefix string - :return bool - ''' - return prefix.find(' ') == -1 and prefix.find(':') == -1 - - -def deserialize_namespace(data): - ''' Deserialize a Namespace object. - - :param data: bytes or str - :return: namespace - ''' - if isinstance(data, bytes): - data = data.decode('utf-8') - kvs = data.split() - uri_to_prefix = {} - for kv in kvs: - i = kv.rfind(':') - if i == -1: - raise ValueError('no colon in namespace ' - 'field {}'.format(repr(kv))) - uri, prefix = kv[0:i], kv[i + 1:] - if not is_valid_schema_uri(uri): - # Currently this can't happen because the only invalid URIs - # are those which contain a space - raise ValueError( - 'invalid URI {} in namespace ' - 'field {}'.format(repr(uri), repr(kv))) - if not is_valid_prefix(prefix): - raise ValueError( - 'invalid prefix {} in namespace field' - ' {}'.format(repr(prefix), repr(kv))) - if uri in uri_to_prefix: - raise ValueError( - 'duplicate URI {} in ' - 'namespace {}'.format(repr(uri), repr(data))) - uri_to_prefix[uri] = prefix - return Namespace(uri_to_prefix) diff --git a/macaroonbakery/checkers/operation.py b/macaroonbakery/checkers/operation.py deleted file mode 100644 index a3b3805..0000000 --- a/macaroonbakery/checkers/operation.py +++ /dev/null @@ -1,17 +0,0 @@ -# Copyright 2017 Canonical Ltd. -# Licensed under the LGPLv3, see LICENCE file for details. -from macaroonbakery.checkers.auth_context import ContextKey - -OP_KEY = ContextKey('op-key') - - -def context_with_operations(ctx, ops): - ''' Returns a context(AuthContext) which is associated with all the given - operations (list of string). It will be based on the auth context - passed in as ctx. - - An allow caveat will succeed only if one of the allowed operations is in - ops; a deny caveat will succeed only if none of the denied operations are - in ops. - ''' - return ctx.with_value(OP_KEY, ops) diff --git a/macaroonbakery/checkers/time.py b/macaroonbakery/checkers/time.py deleted file mode 100644 index 0b52131..0000000 --- a/macaroonbakery/checkers/time.py +++ /dev/null @@ -1,67 +0,0 @@ -# Copyright 2017 Canonical Ltd. -# Licensed under the LGPLv3, see LICENCE file for details. - -import pyrfc3339 - -from macaroonbakery.checkers.auth_context import ContextKey -from macaroonbakery.checkers.conditions import COND_TIME_BEFORE, STD_NAMESPACE -from macaroonbakery.checkers.utils import condition_with_prefix -from macaroonbakery.checkers.caveat import parse_caveat - - -TIME_KEY = ContextKey('time-key') - - -def context_with_clock(ctx, clock): - ''' Returns a copy of ctx with a key added that associates it with the - given clock implementation, which will be used by the time-before checker - to determine the current time. - The clock should have a utcnow method that returns the current time - as a datetime value in UTC. - ''' - if clock is None: - return ctx - return ctx.with_value(TIME_KEY, clock) - - -def macaroons_expiry_time(ns, ms): - ''' Returns the minimum time of any time-before caveats found in the given - macaroons or None if no such caveats were found. - :param ns: a Namespace, used to resolve caveats. - :param ms: a list of pymacaroons.Macaroon - :return: datetime.DateTime or None. - ''' - t = None - for m in ms: - et = expiry_time(ns, m.caveats) - if et is not None and (t is None or et < t): - t = et - return t - - -def expiry_time(ns, cavs): - ''' Returns the minimum time of any time-before caveats found - in the given list or None if no such caveats were found. - - The ns parameter is - :param ns: used to determine the standard namespace prefix - if - the standard namespace is not found, the empty prefix is assumed. - :param cavs: a list of pymacaroons.Caveat - :return: datetime.DateTime or None. - ''' - prefix = ns.resolve(STD_NAMESPACE) - time_before_cond = condition_with_prefix( - prefix, COND_TIME_BEFORE) - t = None - for cav in cavs: - cav = cav.caveat_id_bytes.decode('utf-8') - name, rest = parse_caveat(cav) - if name != time_before_cond: - continue - try: - et = pyrfc3339.parse(rest) - if t is None or et < t: - t = et - except ValueError: - continue - return t diff --git a/macaroonbakery/checkers/utils.py b/macaroonbakery/checkers/utils.py deleted file mode 100644 index 925e8c7..0000000 --- a/macaroonbakery/checkers/utils.py +++ /dev/null @@ -1,13 +0,0 @@ -# Copyright 2017 Canonical Ltd. -# Licensed under the LGPLv3, see LICENCE file for details. - - -def condition_with_prefix(prefix, condition): - '''Returns the given string prefixed by the given prefix. - - If the prefix is non-empty, a colon is used to separate them. - ''' - if prefix == '' or prefix is None: - return condition - - return prefix + ':' + condition diff --git a/macaroonbakery/codec.py b/macaroonbakery/codec.py deleted file mode 100644 index 2946da9..0000000 --- a/macaroonbakery/codec.py +++ /dev/null @@ -1,299 +0,0 @@ -# Copyright 2017 Canonical Ltd. -# Licensed under the LGPLv3, see LICENCE file for details. -import base64 -import json - -import six -import nacl.public - -import macaroonbakery as bakery -import macaroonbakery.checkers as checkers - -_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 == bakery.VERSION_1: - return _encode_caveat_v1(condition, root_key, - third_party_info.public_key, key) - if (third_party_info.version == bakery.VERSION_2 or - third_party_info.version == bakery.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': third_party_pub_key.encode().decode('ascii'), - 'FirstPartyPublicKey': key.public_key.encode().decode('ascii'), - '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 >= bakery.VERSION_3: - ns_data = ns.serialize_text() - data = bytearray() - data.append(version) - data.extend(third_party_pub_key.encode(raw=True)[:_PUBLIC_KEY_PREFIX_LEN]) - data.extend(key.public_key.encode(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 >= bakery.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 bakery.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 == bakery.VERSION_2 or - first_as_int == bakery.VERSION_3): - if (len(caveat) < _VERSION3_CAVEAT_MIN_LEN - and first_as_int == bakery.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 bakery.VerificationError( - 'caveat id payload not provided for caveat id {}'.format( - caveat)) - return _decode_caveat_v2_v3(first_as_int, key, caveat) - raise bakery.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 bakery.ThirdPartyCaveatInfo( - condition=record.get('Condition'), - first_party_public_key=bakery.PublicKey(fp_key), - third_party_key_pair=key, - root_key=base64.b64decode(record.get('RootKey')), - caveat=caveat, - id=None, - version=bakery.VERSION_1, - namespace=bakery.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 bakery.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.encode(raw=True)[:_PUBLIC_KEY_PREFIX_LEN] != pk_prefix: - raise bakery.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 bakery.ThirdPartyCaveatInfo( - condition=condition.decode('utf-8'), - first_party_public_key=bakery.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 bakery.VerificationError('secret part too short') - got_version = six.byte2int(data[:1]) - data = data[1:] - if version != got_version: - raise bakery.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 >= bakery.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 = bakery.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/discharge.py b/macaroonbakery/discharge.py deleted file mode 100644 index f54fc97..0000000 --- a/macaroonbakery/discharge.py +++ /dev/null @@ -1,225 +0,0 @@ -# Copyright 2017 Canonical Ltd. -# Licensed under the LGPLv3, see LICENCE file for details. -import abc -from collections import namedtuple - -import macaroonbakery as bakery -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 local_key is not None and cav.cav.location == 'local': - # 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 bakery.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 = bakery.decode_caveat(key, caveat) - cav_info = bakery.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 bakery.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 = bakery.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 bakery.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 bakery.VerificationError('need-declared caveat with empty required attribute') - if len(need_declared) == 0: - raise bakery.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 bakery.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(bakery.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. - ''' - encoded_key = key.encode().decode('utf-8') - loc = 'local {}'.format(encoded_key) - if version >= bakery.VERSION_2: - loc = 'local {} {}'.format(version, encoded_key) - return checkers.Caveat(location=loc, condition='') diff --git a/macaroonbakery/error.py b/macaroonbakery/error.py deleted file mode 100644 index b403569..0000000 --- a/macaroonbakery/error.py +++ /dev/null @@ -1,77 +0,0 @@ -# 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/httpbakery/__init__.py b/macaroonbakery/httpbakery/__init__.py index 3f183c5..07a805b 100644 --- a/macaroonbakery/httpbakery/__init__.py +++ b/macaroonbakery/httpbakery/__init__.py @@ -1,11 +1,11 @@ # Copyright 2017 Canonical Ltd. # Licensed under the LGPLv3, see LICENCE file for details. -from macaroonbakery.httpbakery.client import ( +from ._client import ( BakeryException, Client, extract_macaroons, ) -from macaroonbakery.httpbakery.error import ( +from ._error import ( BAKERY_PROTOCOL_HEADER, DischargeError, ERR_DISCHARGE_REQUIRED, @@ -17,18 +17,18 @@ from macaroonbakery.httpbakery.error import ( discharge_required_response, request_version, ) -from macaroonbakery.httpbakery.keyring import ThirdPartyLocator -from macaroonbakery.httpbakery.interactor import ( +from ._keyring import ThirdPartyLocator +from ._interactor import ( DischargeToken, Interactor, LegacyInteractor, WEB_BROWSER_INTERACTION_KIND, ) -from macaroonbakery.httpbakery.browser import ( +from ._browser import ( WebBrowserInteractionInfo, WebBrowserInteractor, ) -from macaroonbakery.httpbakery.discharge import discharge +from ._discharge import discharge __all__ = [ 'BAKERY_PROTOCOL_HEADER', diff --git a/macaroonbakery/httpbakery/_browser.py b/macaroonbakery/httpbakery/_browser.py new file mode 100644 index 0000000..a1ccbb0 --- /dev/null +++ b/macaroonbakery/httpbakery/_browser.py @@ -0,0 +1,89 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +import base64 +from collections import namedtuple + +import requests +from ._error import InteractionError +from ._interactor import ( + WEB_BROWSER_INTERACTION_KIND, + DischargeToken, + Interactor, + LegacyInteractor, +) +from macaroonbakery._utils import visit_page_with_browser + +from six.moves.urllib.parse import urljoin + + +class WebBrowserInteractor(Interactor, LegacyInteractor): + ''' Handles web-browser-based interaction-required errors by opening a + web browser to allow the user to prove their credentials interactively. + ''' + def __init__(self, open=visit_page_with_browser): + '''Create a WebBrowserInteractor that uses the given function + to open a browser window. The open function is expected to take + a single argument of string type, the URL to open. + ''' + self._open_web_browser = open + + def kind(self): + return WEB_BROWSER_INTERACTION_KIND + + def legacy_interact(self, ctx, location, visit_url): + '''Implement LegacyInteractor.legacy_interact by opening the + web browser window''' + self._open_web_browser(visit_url) + + def interact(self, ctx, location, ir_err): + '''Implement Interactor.interact by opening the browser window + and waiting for the discharge token''' + p = ir_err.interaction_method(self.kind(), WebBrowserInteractionInfo) + if not location.endswith('/'): + location += '/' + visit_url = urljoin(location, p.visit_url) + wait_token_url = urljoin(location, p.wait_token_url) + self._open_web_browser(visit_url) + return self._wait_for_token(ctx, wait_token_url) + + def _wait_for_token(self, ctx, wait_token_url): + ''' Returns a token from a the wait token URL + @param wait_token_url URL to wait for (string) + :return DischargeToken + ''' + resp = requests.get(wait_token_url) + if resp.status_code != 200: + raise InteractionError('cannot get {}'.format(wait_token_url)) + json_resp = resp.json() + kind = json_resp.get('kind') + if kind is None: + raise InteractionError( + 'cannot get kind token from {}'.format(wait_token_url)) + token_val = json_resp.get('token') + if token_val is None: + token_val = json_resp.get('token64') + if token_val is None: + raise InteractionError( + 'cannot get token from {}'.format(wait_token_url)) + token_val = base64.b64decode(token_val) + return DischargeToken(kind=kind, value=token_val) + + +class WebBrowserInteractionInfo(namedtuple('WebBrowserInteractionInfo', + 'visit_url, wait_token_url')): + ''' holds the information expected in the browser-window interaction + entry in an interaction-required error. + + :param visit_url holds the URL to be visited in a web browser. + :param wait_token_url holds a URL that will block on GET until the browser + interaction has completed. + ''' + @classmethod + def from_dict(cls, info_dict): + '''Create a new instance of WebBrowserInteractionInfo, as expected + by the Error.interaction_method method. + @param info_dict The deserialized JSON object + @return a new WebBrowserInteractionInfo object. + ''' + return WebBrowserInteractionInfo(visit_url=info_dict.get('VisitURL'), + wait_token_url=info_dict('WaitURL')) diff --git a/macaroonbakery/httpbakery/_client.py b/macaroonbakery/httpbakery/_client.py new file mode 100644 index 0000000..d877140 --- /dev/null +++ b/macaroonbakery/httpbakery/_client.py @@ -0,0 +1,408 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +import base64 +import json +import logging + +import macaroonbakery.bakery as bakery +import macaroonbakery.checkers as checkers +import macaroonbakery._utils as utils +from ._browser import WebBrowserInteractor +from ._error import ( + BAKERY_PROTOCOL_HEADER, + ERR_DISCHARGE_REQUIRED, + ERR_INTERACTION_REQUIRED, + DischargeError, + Error, + InteractionError, + InteractionMethodNotFound, +) +from ._interactor import ( + WEB_BROWSER_INTERACTION_KIND, + LegacyInteractor, +) + +import requests +from six.moves.http_cookies import SimpleCookie +from six.moves.urllib.parse import urljoin + +TIME_OUT = 30 +MAX_DISCHARGE_RETRIES = 3 + +log = logging.getLogger('httpbakery') + + +class BakeryException(requests.RequestException): + '''Raised when some errors happen using the httpbakery + authorizer''' + + +class Client: + '''Client holds the context for making HTTP requests with macaroons. + To make a request, use the auth method to obtain + an HTTP authorizer suitable for passing as the auth parameter + to a requests method. Note that the same cookie jar + should be passed to requests as is used to initialize + the client. + For example: + import macaroonbakery.httpbakery + client = httpbakery.Client() + resp = requests.get('some protected url', + cookies=client.cookies, + auth=client.auth()) + @param interaction_methods A list of Interactor implementations. + @param key The private key of the client {bakery.PrivateKey} + @param cookies storage for the cookies {CookieJar}. It should be the + same as in the requests cookies. If not provided, one + will be created. + ''' + def __init__(self, interaction_methods=None, key=None, cookies=None): + if interaction_methods is None: + interaction_methods = [WebBrowserInteractor()] + if cookies is None: + cookies = requests.cookies.RequestsCookieJar() + self._interaction_methods = interaction_methods + self.key = key + self.cookies = cookies + + def auth(self): + '''Return an authorizer object suitable for passing + to requests methods that accept one. + If a request returns a discharge-required error, + the authorizer will acquire discharge macaroons + and retry the request. + ''' + return _BakeryAuth(self) + + def request(self, method, url, **kwargs): + '''Use the requests library to make a request. + Using this method is like doing: + + requests.request(method, url, auth=client.auth()) + ''' + # TODO should we raise an exception if auth or cookies are explicitly + # mentioned in kwargs? + kwargs['auth'] = self.auth() + kwargs['cookies'] = self.cookies + return requests.request(method=method, url=url, **kwargs) + + def handle_error(self, error, url): + '''Try to resolve the given error, which should be a response + to the given URL, by discharging any macaroon contained in + it. That is, if error.code is ERR_DISCHARGE_REQUIRED + then it will try to discharge err.info.macaroon. If the discharge + succeeds, the discharged macaroon will be saved to the client's cookie + jar, otherwise an exception will be raised. + ''' + if error.info is None or error.info.macaroon is None: + raise BakeryException('unable to read info in discharge error ' + 'response') + + discharges = bakery.discharge_all( + error.info.macaroon, + self.acquire_discharge, + self.key, + ) + macaroons = '[' + ','.join(map(utils.macaroon_to_json_string, + discharges)) + ']' + all_macaroons = base64.urlsafe_b64encode(utils.to_bytes(macaroons)) + + full_path = relative_url(url, error.info.macaroon_path) + if error.info.cookie_name_suffix is not None: + name = 'macaroon-' + error.info.cookie_name_suffix + else: + name = 'macaroon-auth' + expires = checkers.macaroons_expiry_time(checkers.Namespace(), discharges) + self.cookies.set_cookie(utils.cookie( + name=name, + value=all_macaroons.decode('ascii'), + url=full_path, + expires=expires, + )) + + def acquire_discharge(self, cav, payload): + ''' Request a discharge macaroon from the caveat location + as an HTTP URL. + @param cav Third party {pymacaroons.Caveat} to be discharged. + @param payload External caveat data {bytes}. + @return The acquired macaroon {macaroonbakery.Macaroon} + ''' + resp = self._acquire_discharge_with_token(cav, payload, None) + # TODO Fabrice what is the other http response possible ?? + if resp.status_code == 200: + return bakery.Macaroon.from_dict(resp.json().get('Macaroon')) + cause = Error.from_dict(resp.json()) + if cause.code != ERR_INTERACTION_REQUIRED: + raise DischargeError(cause.message) + if cause.info is None: + raise DischargeError( + 'interaction-required response with no info: {}'.format( + resp.json()) + ) + loc = cav.location + if not loc.endswith('/'): + loc = loc + '/' + token, m = self._interact(loc, cause, payload) + if m is not None: + # We've acquired the macaroon directly via legacy interaction. + return m + # Try to acquire the discharge again, but this time with + # the token acquired by the interaction method. + resp = self._acquire_discharge_with_token(cav, payload, token) + if resp.status_code == 200: + return bakery.Macaroon.from_dict(resp.json().get('Macaroon')) + else: + raise DischargeError( + 'discharge failed with code {}'.format(resp.status_code)) + + def _acquire_discharge_with_token(self, cav, payload, token): + req = {} + _add_json_binary_field(cav.caveat_id_bytes, req, 'id') + if token is not None: + _add_json_binary_field(token.value, req, 'token') + req['token-kind'] = token.kind + if payload is not None: + req['caveat64'] = base64.urlsafe_b64encode(payload).rstrip( + b'=').decode('utf-8') + target = relative_url(cav.location, 'discharge') + headers = { + BAKERY_PROTOCOL_HEADER: str(bakery.LATEST_VERSION) + } + return self.request('POST', target, data=req, headers=headers) + + def _interact(self, location, error_info, payload): + '''Gathers a macaroon by directing the user to interact with a + web page. The error_info argument holds the interaction-required + error response. + @return DischargeToken, bakery.Macaroon + ''' + if (self._interaction_methods is None or + len(self._interaction_methods) == 0): + raise InteractionError('interaction required but not possible') + # TODO(rogpeppe) make the robust against a wider range of error info. + if error_info.info.interaction_methods is None and \ + error_info.info.visit_url is not None: + # It's an old-style error; deal with it differently. + return None, self._legacy_interact(location, error_info) + for interactor in self._interaction_methods: + found = error_info.info.interaction_methods.get(interactor.kind()) + if found is None: + continue + try: + token = interactor.interact(self, location, error_info) + except InteractionMethodNotFound: + continue + if token is None: + raise InteractionError('interaction method returned an empty ' + 'token') + return token, None + + raise InteractionError('no supported interaction method') + + def _legacy_interact(self, location, error_info): + visit_url = relative_url(location, error_info.info.visit_url) + wait_url = relative_url(location, error_info.info.wait_url) + method_urls = { + "interactive": visit_url + } + if (len(self._interaction_methods) > 1 or + self._interaction_methods[0].kind() != + WEB_BROWSER_INTERACTION_KIND): + # We have several possible methods or we only support a non-window + # method, so we need to fetch the possible methods supported by + # the discharger. + method_urls = _legacy_get_interaction_methods(visit_url) + for interactor in self._interaction_methods: + kind = interactor.kind() + if kind == WEB_BROWSER_INTERACTION_KIND: + # This is the old name for browser-window interaction. + kind = "interactive" + + if not isinstance(interactor, LegacyInteractor): + # Legacy interaction mode isn't supported. + continue + + visit_url = method_urls.get(kind) + if visit_url is None: + continue + + visit_url = relative_url(location, visit_url) + interactor.legacy_interact(self, location, visit_url) + return _wait_for_macaroon(wait_url) + + raise InteractionError('no methods supported; supported [{}]; provided [{}]'.format( + ' '.join([x.kind() for x in self._interaction_methods]), + ' '.join(method_urls.keys()), + )) + + +class _BakeryAuth: + '''_BakeryAuth implements an authorizer as required + by the requests HTTP client. + ''' + def __init__(self, client): + ''' + @param interaction_methods A list of Interactor implementations. + @param key The private key of the client (macaroonbakery.PrivateKey) + @param cookies storage for the cookies {CookieJar}. It should be the + same as in the requests cookies. + ''' + self._client = client + + def __call__(self, req): + req.headers[BAKERY_PROTOCOL_HEADER] = str(bakery.LATEST_VERSION) + hook = _prepare_discharge_hook(req.copy(), self._client) + req.register_hook(event='response', hook=hook) + return req + + +def _prepare_discharge_hook(req, client): + ''' Return the hook function (called when the response is received.) + + This allows us to intercept the response and do any necessary + macaroon discharge before returning. + ''' + class Retry: + # Define a local class so that we can use its class variable as + # mutable state accessed by the closures below. + count = 0 + + def hook(response, *args, **kwargs): + ''' Requests hooks system, this is the hook for the response. + ''' + status_code = response.status_code + + if status_code != 407 and status_code != 401: + return response + if (status_code == 401 and response.headers.get('WWW-Authenticate') != + 'Macaroon'): + return response + + if response.headers.get('Content-Type') != 'application/json': + return response + errorJSON = response.json() + if errorJSON.get('Code') != ERR_DISCHARGE_REQUIRED: + return response + error = Error.from_dict(errorJSON) + Retry.count += 1 + if Retry.count >= MAX_DISCHARGE_RETRIES: + raise BakeryException('too many ({}) discharge requests'.format( + Retry.count) + ) + client.handle_error(error, req.url) + # Replace the private _cookies from req as it is a copy of + # the original cookie jar passed into the requests method and we need + # to set the cookie for this request. + req._cookies = client.cookies + req.headers.pop('Cookie', None) + req.prepare_cookies(req._cookies) + req.headers[BAKERY_PROTOCOL_HEADER] = \ + str(bakery.LATEST_VERSION) + with requests.Session() as s: + return s.send(req) + return hook + + +def extract_macaroons(headers_or_request): + ''' Returns an array of any macaroons found in the given slice of cookies. + If the argument implements a get_header method, that will be used + instead of the get method to retrieve headers. + @param headers_or_request: dict of headers or a + urllib.request.Request-like object. + @return: A list of list of mpy macaroons + ''' + def get_header(key, default=None): + try: + return headers_or_request.get_header(key, default) + except AttributeError: + return headers_or_request.get(key, default) + + mss = [] + + def add_macaroon(data): + data = utils.b64decode(data) + data_as_objs = json.loads(data.decode('utf-8')) + ms = [utils.macaroon_from_dict(x) for x in data_as_objs] + mss.append(ms) + + cookie_header = get_header('Cookie') + if cookie_header is not None: + cs = SimpleCookie() + # The cookie might be a unicode object, so convert it + # to ASCII. This may cause an exception under Python 2. + # TODO is that a problem? + cs.load(str(cookie_header)) + for c in cs: + if c.startswith('macaroon-'): + add_macaroon(cs[c].value) + # Python doesn't make it easy to have multiple values for a + # key, so split the header instead, which is necessary + # for HTTP1.1 compatibility anyway (see RFC 7230, section 3.2.2) + macaroon_header = get_header('Macaroons') + if macaroon_header is not None: + for h in macaroon_header.split(','): + add_macaroon(h) + return mss + + +def _add_json_binary_field(b, serialized, field): + '''' Set the given field to the given val (bytes) in the serialized + dictionary. + If the value isn't valid utf-8, we base64 encode it and use field+"64" + as the field name. + ''' + try: + val = b.decode('utf-8') + serialized[field] = val + except UnicodeDecodeError: + val = base64.b64encode(b).decode('utf-8') + serialized[field + '64'] = val + + +def _wait_for_macaroon(wait_url): + ''' Returns a macaroon from a legacy wait endpoint. + ''' + headers = { + BAKERY_PROTOCOL_HEADER: str(bakery.LATEST_VERSION) + } + resp = requests.get(url=wait_url, headers=headers) + if resp.status_code != 200: + raise InteractionError('cannot get {}'.format(wait_url)) + + return bakery.Macaroon.from_dict(resp.json().get('Macaroon')) + + +def relative_url(base, new): + ''' Returns new path relative to an original URL. + ''' + if new == '': + return base + if not base.endswith('/'): + base += '/' + return urljoin(base, new) + + +def _legacy_get_interaction_methods(u): + ''' Queries a URL as found in an ErrInteractionRequired VisitURL field to + find available interaction methods. + It does this by sending a GET request to the URL with the Accept + header set to "application/json" and parsing the resulting + response as a dict. + ''' + headers = { + BAKERY_PROTOCOL_HEADER: str(bakery.LATEST_VERSION), + 'Accept': 'application/json' + } + resp = requests.get(url=u, headers=headers) + method_urls = {} + if resp.status_code == 200: + json_resp = resp.json() + for m in json_resp: + method_urls[m] = relative_url(u, json_resp[m]) + + if method_urls.get('interactive') is None: + # There's no "interactive" method returned, but we know + # the server does actually support it, because all dischargers + # are required to, so fill it in with the original URL. + method_urls['interactive'] = u + return method_urls diff --git a/macaroonbakery/httpbakery/_discharge.py b/macaroonbakery/httpbakery/_discharge.py new file mode 100644 index 0000000..f868d23 --- /dev/null +++ b/macaroonbakery/httpbakery/_discharge.py @@ -0,0 +1,34 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +import macaroonbakery.bakery as bakery +import macaroonbakery._utils as utils + + +def discharge(ctx, content, key, locator, checker): + '''Handles a discharge request as received by the /discharge + endpoint. + @param ctx The context passed to the checker {checkers.AuthContext} + @param content URL and form parameters {dict} + @param locator Locator used to add third party caveats returned by + the checker {macaroonbakery.ThirdPartyLocator} + @param checker {macaroonbakery.ThirdPartyCaveatChecker} Used to check third + party caveats. + @return The discharge macaroon {macaroonbakery.Macaroon} + ''' + id = content.get('id') + if id is None: + id = content.get('id64') + if id is not None: + id = utils.b64decode(id) + caveat = content.get('caveat64') + if caveat is not None: + caveat = utils.b64decode(caveat) + + return bakery.discharge( + ctx, + id=id, + caveat=caveat, + key=key, + checker=checker, + locator=locator, + ) diff --git a/macaroonbakery/httpbakery/_error.py b/macaroonbakery/httpbakery/_error.py new file mode 100644 index 0000000..ff75f13 --- /dev/null +++ b/macaroonbakery/httpbakery/_error.py @@ -0,0 +1,202 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +import json +from collections import namedtuple + +import macaroonbakery.bakery as bakery + +ERR_INTERACTION_REQUIRED = 'interaction required' +ERR_DISCHARGE_REQUIRED = 'macaroon discharge required' + + +class InteractionMethodNotFound(Exception): + '''This is thrown by client-side interaction methods when + they find that a given interaction isn't supported by the + client for a location''' + pass + + +class DischargeError(Exception): + '''This is thrown by Client when a third party has refused a discharge''' + def __init__(self, msg): + super(DischargeError, self).__init__( + 'third party refused discharge: {}'.format(msg)) + + +class InteractionError(Exception): + '''This is thrown by Client when it fails to deal with an + interaction-required error + ''' + def __init__(self, msg): + super(InteractionError, self).__init__( + 'cannot start interactive session: {}'.format(msg)) + + +def discharge_required_response(macaroon, path, cookie_suffix_name, + message=None): + ''' Get response content and headers from a discharge macaroons error. + + @param macaroon may hold a macaroon that, when discharged, may + allow access to a service. + @param path holds the URL path to be associated with the macaroon. + The macaroon is potentially valid for all URLs under the given path. + @param cookie_suffix_name holds the desired cookie name suffix to be + associated with the macaroon. The actual name used will be + ("macaroon-" + CookieName). Clients may ignore this field - + older clients will always use ("macaroon-" + macaroon.signature() in hex) + @return content(bytes) and the headers to set on the response(dict). + ''' + if message is None: + message = 'discharge required' + content = json.dumps( + { + 'Code': 'macaroon discharge required', + 'Message': message, + 'Info': { + 'Macaroon': macaroon.to_dict(), + 'MacaroonPath': path, + 'CookieNameSuffix': cookie_suffix_name + }, + } + ).encode('utf-8') + return content, { + 'WWW-Authenticate': 'Macaroon', + 'Content-Type': 'application/json' + } + +# BAKERY_PROTOCOL_HEADER is the header that HTTP clients should set +# to determine the bakery protocol version. If it is 0 or missing, +# a discharge-required error response will be returned with HTTP status 407; +# if it is greater than 0, the response will have status 401 with the +# WWW-Authenticate header set to "Macaroon". +BAKERY_PROTOCOL_HEADER = 'Bakery-Protocol-Version' + + +def request_version(req_headers): + ''' Determines the bakery protocol version from a client request. + If the protocol cannot be determined, or is invalid, the original version + of the protocol is used. If a later version is found, the latest known + version is used, which is OK because versions are backwardly compatible. + + @param req_headers: the request headers as a dict. + @return: bakery protocol version (for example macaroonbakery.VERSION_1) + ''' + vs = req_headers.get(BAKERY_PROTOCOL_HEADER) + if vs is None: + # No header - use backward compatibility mode. + return bakery.VERSION_1 + try: + x = int(vs) + except ValueError: + # Badly formed header - use backward compatibility mode. + return bakery.VERSION_1 + if x > bakery.LATEST_VERSION: + # Later version than we know about - use the + # latest version that we can. + return bakery.LATEST_VERSION + return x + + +class Error(namedtuple('Error', 'code, message, version, info')): + '''This class defines an error value as returned from + an httpbakery API. + ''' + @classmethod + def from_dict(cls, serialized): + '''Create an error from a JSON-deserialized object + @param serialized the object holding the serialized error {dict} + ''' + code = serialized.get('Code') + message = serialized.get('Message') + info = ErrorInfo.from_dict(serialized.get('Info')) + return Error(code=code, message=message, info=info, + version=bakery.LATEST_VERSION) + + def interaction_method(self, kind, x): + ''' Checks whether the error is an InteractionRequired error + that implements the method with the given name, and JSON-unmarshals the + method-specific data into x by calling its from_dict method + with the deserialized JSON object. + @param kind The interaction method kind (string). + @param x A class with a class method from_dict that returns a new + instance of the interaction info for the given kind. + @return The result of x.from_dict. + ''' + if self.info is None or self.code != ERR_INTERACTION_REQUIRED: + raise InteractionError( + 'not an interaction-required error (code {})'.format( + self.code) + ) + entry = self.info.interaction_methods.get(kind) + if entry is None: + raise InteractionMethodNotFound( + 'interaction method {} not found'.format(kind) + ) + return x.from_dict(entry) + + +class ErrorInfo( + namedtuple('ErrorInfo', 'macaroon, macaroon_path, cookie_name_suffix, ' + 'interaction_methods, visit_url, wait_url')): + ''' Holds additional information provided + by an error. + + @param macaroon may hold a macaroon that, when + discharged, may allow access to a service. + This field is associated with the ERR_DISCHARGE_REQUIRED + error code. + + @param macaroon_path holds the URL path to be associated + with the macaroon. The macaroon is potentially + valid for all URLs under the given path. + If it is empty, the macaroon will be associated with + the original URL from which the error was returned. + + @param cookie_name_suffix holds the desired cookie name suffix to be + associated with the macaroon. The actual name used will be + ("macaroon-" + cookie_name_suffix). Clients may ignore this field - + older clients will always use ("macaroon-" + + macaroon.signature() in hex). + + @param visit_url holds a URL that the client should visit + in a web browser to authenticate themselves. + + @param wait_url holds a URL that the client should visit + to acquire the discharge macaroon. A GET on + this URL will block until the client has authenticated, + and then it will return the discharge macaroon. + ''' + + __slots__ = () + + @classmethod + def from_dict(cls, serialized): + '''Create a new ErrorInfo object from a JSON deserialized + dictionary + @param serialized The JSON object {dict} + @return ErrorInfo object + ''' + if serialized is None: + return None + macaroon = serialized.get('Macaroon') + if macaroon is not None: + macaroon = bakery.Macaroon.from_dict(macaroon) + path = serialized.get('MacaroonPath') + cookie_name_suffix = serialized.get('CookieNameSuffix') + visit_url = serialized.get('VisitURL') + wait_url = serialized.get('WaitURL') + interaction_methods = serialized.get('InteractionMethods') + return ErrorInfo(macaroon=macaroon, macaroon_path=path, + cookie_name_suffix=cookie_name_suffix, + visit_url=visit_url, wait_url=wait_url, + interaction_methods=interaction_methods) + + def __new__(cls, macaroon=None, macaroon_path=None, + cookie_name_suffix=None, interaction_methods=None, + visit_url=None, wait_url=None): + '''Override the __new__ method so that we can + have optional arguments, which namedtuple doesn't + allow''' + return super(ErrorInfo, cls).__new__( + cls, macaroon, macaroon_path, cookie_name_suffix, + interaction_methods, visit_url, wait_url) diff --git a/macaroonbakery/httpbakery/_interactor.py b/macaroonbakery/httpbakery/_interactor.py new file mode 100644 index 0000000..7fba4ef --- /dev/null +++ b/macaroonbakery/httpbakery/_interactor.py @@ -0,0 +1,70 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +import abc +from collections import namedtuple + +WEB_BROWSER_INTERACTION_KIND = 'browser-window' + + +class Interactor(object): + ''' Represents a way of persuading a discharger that it should grant a + discharge macaroon. + ''' + __metaclass__ = abc.ABCMeta + + @abc.abstractmethod + def kind(self): + '''Returns the interaction method name. This corresponds to the key in + the Error.interaction_methods type. + @return {str} + ''' + raise NotImplementedError('kind method must be defined in subclass') + + def interact(self, client, location, interaction_required_err): + ''' Performs the interaction, and returns a token that can be + used to acquire the discharge macaroon. The location provides + the third party caveat location to make it possible to use + relative URLs. The client holds the client being used to do the current + request. + + If the given interaction isn't supported by the client for + the given location, it may raise an InteractionMethodNotFound + which will cause the interactor to be ignored that time. + @param client The client being used for the current request {Client} + @param location Third party caveat location {str} + @param interaction_required_err The error causing the interaction to + take place {Error} + @return {DischargeToken} The discharge token. + ''' + raise NotImplementedError('interact method must be defined in subclass') + + +class LegacyInteractor(object): + ''' May optionally be implemented by Interactor implementations that + implement the legacy interaction-required error protocols. + ''' + __metaclass__ = abc.ABCMeta + + @abc.abstractmethod + def legacy_interact(self, client, location, visit_url): + ''' Implements the "visit" half of a legacy discharge + interaction. The "wait" half will be implemented by httpbakery. + The location is the location specified by the third party + caveat. The client holds the client being used to do the current + request. + @param client The client being used for the current request {Client} + @param location Third party caveat location {str} + @param visit_url The visit_url field from the error {str} + @return None + ''' + raise NotImplementedError('legacy_interact method must be defined in subclass') + + +class DischargeToken(namedtuple('DischargeToken', 'kind, value')): + ''' Holds a token that is intended to persuade a discharger to discharge + a third party caveat. + @param kind holds the kind of the token. By convention this + matches the name of the interaction method used to + obtain the token, but that's not required {str} + @param value holds the token data. {bytes} + ''' diff --git a/macaroonbakery/httpbakery/_keyring.py b/macaroonbakery/httpbakery/_keyring.py new file mode 100644 index 0000000..8d9ab43 --- /dev/null +++ b/macaroonbakery/httpbakery/_keyring.py @@ -0,0 +1,60 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +import macaroonbakery.bakery as bakery +import requests +from ._error import BAKERY_PROTOCOL_HEADER + +from six.moves.urllib.parse import urlparse + + +class ThirdPartyLocator(bakery.ThirdPartyLocator): + ''' Implements macaroonbakery.ThirdPartyLocator by first looking in the + backing cache and, if that fails, making an HTTP request to find the + information associated with the given discharge location. + ''' + + def __init__(self, allow_insecure=False): + ''' + @param url: the url to retrieve public_key + @param allow_insecure: By default it refuses to use insecure URLs. + ''' + self._allow_insecure = allow_insecure + self._cache = {} + + def third_party_info(self, loc): + u = urlparse(loc) + if u.scheme != 'https' and not self._allow_insecure: + raise bakery.ThirdPartyInfoNotFound( + 'untrusted discharge URL {}'.format(loc)) + loc = loc.rstrip('/') + info = self._cache.get(loc) + if info is not None: + return info + url_endpoint = '/discharge/info' + headers = { + BAKERY_PROTOCOL_HEADER: str(bakery.LATEST_VERSION) + } + resp = requests.get(url=loc + url_endpoint, headers=headers) + status_code = resp.status_code + if status_code == 404: + url_endpoint = '/publickey' + resp = requests.get(url=loc + url_endpoint, headers=headers) + status_code = resp.status_code + if status_code != 200: + raise bakery.ThirdPartyInfoNotFound( + 'unable to get info from {}'.format(url_endpoint)) + json_resp = resp.json() + if json_resp is None: + raise bakery.ThirdPartyInfoNotFound( + 'no response from /discharge/info') + pk = json_resp.get('PublicKey') + if pk is None: + raise bakery.ThirdPartyInfoNotFound( + 'no public key found in /discharge/info') + idm_pk = bakery.PublicKey.deserialize(pk) + version = json_resp.get('Version', bakery.VERSION_1) + self._cache[loc] = bakery.ThirdPartyInfo( + version=version, + public_key=idm_pk + ) + return self._cache.get(loc) diff --git a/macaroonbakery/httpbakery/agent/__init__.py b/macaroonbakery/httpbakery/agent/__init__.py index db252de..c0a7523 100644 --- a/macaroonbakery/httpbakery/agent/__init__.py +++ b/macaroonbakery/httpbakery/agent/__init__.py @@ -1,8 +1,9 @@ # Copyright 2017 Canonical Ltd. # Licensed under the LGPLv3, see LICENCE file for details. -from macaroonbakery.httpbakery.agent.agent import ( - load_agent_file, +from ._agent import ( + load_auth_info, + read_auth_info, Agent, AgentInteractor, AgentFileFormatError, @@ -13,5 +14,6 @@ __all__ = [ 'AgentFileFormatError', 'AgentInteractor', 'AuthInfo', - 'load_agent_file', + 'load_auth_info', + 'read_auth_info', ] diff --git a/macaroonbakery/httpbakery/agent/_agent.py b/macaroonbakery/httpbakery/agent/_agent.py new file mode 100644 index 0000000..b717261 --- /dev/null +++ b/macaroonbakery/httpbakery/agent/_agent.py @@ -0,0 +1,184 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +import copy +import json +import logging +from collections import namedtuple + +import macaroonbakery.bakery as bakery +import macaroonbakery.httpbakery as httpbakery +import macaroonbakery._utils as utils +import requests.cookies + +from six.moves.urllib.parse import urljoin + +log = logging.getLogger(__name__) + + +class AgentFileFormatError(Exception): + ''' AgentFileFormatError is the exception raised when an agent file has a + bad structure. + ''' + pass + + +def load_auth_info(filename): + '''Loads agent authentication information from the specified file. + The returned information is suitable for passing as an argument + to the AgentInteractor constructor. + @param filename The name of the file to open (str) + @return AuthInfo The authentication information + @raises AgentFileFormatError when the file format is bad. + ''' + with open(filename) as f: + return read_auth_info(f.read()) + + +def read_auth_info(agent_file_content): + '''Loads agent authentication information from the + specified content string, as read from an agents file. + The returned information is suitable for passing as an argument + to the AgentInteractor constructor. + @param agent_file_content The agent file content (str) + @return AuthInfo The authentication information + @raises AgentFileFormatError when the file format is bad. + ''' + try: + data = json.loads(agent_file_content) + return AuthInfo( + key=bakery.PrivateKey.deserialize(data['key']['private']), + agents=list( + Agent(url=a['url'], username=a['username']) + for a in data.get('agents', []) + ), + ) + except ( + KeyError, + ValueError, + TypeError, + ) as e: + raise AgentFileFormatError('invalid agent file', e) + + +class InteractionInfo(object): + '''Holds the information expected in the agent interaction entry in an + interaction-required error. + ''' + def __init__(self, login_url): + self._login_url = login_url + + @property + def login_url(self): + ''' Return the URL from which to acquire a macaroon that can be used + to complete the agent login. To acquire the macaroon, make a POST + request to the URL with user and public-key parameters. + :return string + ''' + return self._login_url + + @classmethod + def from_dict(cls, json_dict): + '''Return an InteractionInfo obtained from the given dictionary as + deserialized from JSON. + @param json_dict The deserialized JSON object. + ''' + return InteractionInfo(json_dict.get('login-url')) + + +class AgentInteractor(httpbakery.Interactor, httpbakery.LegacyInteractor): + ''' Interactor that performs interaction using the agent login protocol. + ''' + def __init__(self, auth_info): + self._auth_info = auth_info + + def kind(self): + '''Implement Interactor.kind by returning the agent kind''' + return 'agent' + + def interact(self, client, location, interaction_required_err): + '''Implement Interactor.interact by obtaining obtaining + a macaroon from the discharger, discharging it with the + local private key using the discharged macaroon as + a discharge token''' + p = interaction_required_err.interaction_method('agent', + InteractionInfo) + if p.login_url is None or p.login_url == '': + raise httpbakery.InteractionError( + 'no login-url field found in agent interaction method') + agent = self._find_agent(location) + if not location.endswith('/'): + location += '/' + login_url = urljoin(location, p.login_url) + # TODO use client to make the request. + resp = requests.get(login_url, json={ + 'Username': agent.username, + 'PublicKey': str(self._auth_info.key), + }) + if resp.status_code != 200: + raise httpbakery.InteractionError( + 'cannot acquire agent macaroon: {}'.format(resp.status_code) + ) + m = resp.json().get('macaroon') + if m is None: + raise httpbakery.InteractionError('no macaroon in response') + m = bakery.Macaroon.from_dict(m) + ms = bakery.discharge_all(m, None, self._auth_info.key) + b = bytearray() + for m in ms: + b.extend(utils.b64decode(m.serialize())) + return httpbakery.DischargeToken(kind='agent', value=bytes(b)) + + def _find_agent(self, location): + ''' Finds an appropriate agent entry for the given location. + :return Agent + ''' + for a in self._auth_info.agents: + # Don't worry about trailing slashes + if a.url.rstrip('/') == location.rstrip('/'): + return a + raise httpbakery.InteractionMethodNotFound( + 'cannot find username for discharge location {}'.format(location)) + + def legacy_interact(self, client, location, visit_url): + '''Implement LegacyInteractor.legacy_interact by obtaining + the discharge macaroon using the client's private key + ''' + agent = self._find_agent(location) + # Shallow-copy the client so that we don't unexpectedly side-effect + # it by changing the key. Another possibility might be to + # set up agent authentication differently, in such a way that + # we're sure that client.key is the same as self._auth_info.key. + client = copy.copy(client) + client.key = self._auth_info.key + resp = client.request( + method='POST', + url=visit_url, + json={ + 'username': agent.username, + 'public_key': str(self._auth_info.key.public_key), + }, + ) + if resp.status_code != 200: + raise httpbakery.InteractionError( + 'cannot acquire agent macaroon from {}: {} (response body: {!r})'.format(visit_url, resp.status_code, resp.text)) + if not resp.json().get('agent_login', False): + raise httpbakery.InteractionError('agent login failed') + + +class Agent(namedtuple('Agent', 'url, username')): + ''' Represents an agent that can be used for agent authentication. + @param url(string) holds the URL of the discharger that knows about + the agent. + @param username holds the username agent (string). + ''' + + +class AuthInfo(namedtuple('AuthInfo', 'key, agents')): + ''' Holds the agent information required to set up agent authentication + information. + + It holds the agent's private key and information about the username + associated with each known agent-authentication server. + @param key the agent's private key (bakery.PrivateKey). + @param agents information about the known agents (list of Agent). + ''' diff --git a/macaroonbakery/httpbakery/agent/agent.py b/macaroonbakery/httpbakery/agent/agent.py deleted file mode 100644 index ad56015..0000000 --- a/macaroonbakery/httpbakery/agent/agent.py +++ /dev/null @@ -1,183 +0,0 @@ -# Copyright 2017 Canonical Ltd. -# Licensed under the LGPLv3, see LICENCE file for details. -import base64 -from collections import namedtuple -import json - -import nacl.public -import nacl.encoding -import nacl.exceptions -import requests.cookies -import six -from six.moves.urllib.parse import urlparse -from six.moves.urllib.parse import urljoin - -import macaroonbakery as bakery -import macaroonbakery.utils as utils -import macaroonbakery.httpbakery as httpbakery - - -class AgentFileFormatError(Exception): - ''' AgentFileFormatError is the exception raised when an agent file has a - bad structure. - ''' - pass - - -def load_agent_file(filename, cookies=None): - ''' Loads agent information from the specified file. - - The agent cookies are added to cookies, or a newly created cookie jar - if cookies is not specified. The updated cookies is returned along - with the private key associated with the agent. These can be passed - directly as the cookies and key parameter to BakeryAuth. - ''' - - with open(filename) as f: - data = json.load(f) - try: - key = nacl.public.PrivateKey( - data['key']['private'], - nacl.encoding.Base64Encoder, - ) - if cookies is None: - cookies = requests.cookies.RequestsCookieJar() - for agent in data['agents']: - u = urlparse(agent['url']) - value = {'username': agent['username'], - 'public_key': data['key']['public']} - jv = json.dumps(value) - if six.PY3: - jv = jv.encode('utf-8') - v = base64.b64encode(jv) - if six.PY3: - v = v.decode('utf-8') - cookie = requests.cookies.create_cookie('agent-login', v, - domain=u.netloc, - path=u.path) - cookies.set_cookie(cookie) - return cookies, key - except (KeyError, ValueError, nacl.exceptions.TypeError) as e: - raise AgentFileFormatError('invalid agent file', e) - - -class InteractionInfo(object): - '''Holds the information expected in the agent interaction entry in an - interaction-required error. - ''' - def __init__(self, login_url): - self._login_url = login_url - - @property - def login_url(self): - ''' Return the URL from which to acquire a macaroon that can be used - to complete the agent login. To acquire the macaroon, make a POST - request to the URL with user and public-key parameters. - :return string - ''' - return self._login_url - - @classmethod - def from_dict(cls, json_dict): - '''Return an InteractionInfo obtained from the given dictionary as - deserialized from JSON. - @param json_dict The deserialized JSON object. - ''' - return InteractionInfo(json_dict.get('login-url')) - - -class AgentInteractor(httpbakery.Interactor, httpbakery.LegacyInteractor): - ''' Interactor that performs interaction using the agent login protocol. - ''' - def __init__(self, auth_info): - self._auth_info = auth_info - - def kind(self): - '''Implement Interactor.kind by returning the agent kind''' - return 'agent' - - def interact(self, client, location, interaction_required_err): - '''Implement Interactor.interact by obtaining obtaining - a macaroon from the discharger, discharging it with the - local private key using the discharged macaroon as - a discharge token''' - p = interaction_required_err.interaction_method('agent', - InteractionInfo) - if p.login_url is None or p.login_url == '': - raise httpbakery.InteractionError( - 'no login-url field found in agent interaction method') - agent = self._find_agent(location) - if not location.endswith('/'): - location += '/' - login_url = urljoin(location, p.login_url) - resp = requests.get(login_url, json={ - 'Username': agent.username, - 'PublicKey': self._auth_info.key.encode().decode('utf-8'), - }) - if resp.status_code != 200: - raise httpbakery.InteractionError( - 'cannot acquire agent macaroon: {}'.format(resp.status_code) - ) - m = resp.json().get('macaroon') - if m is None: - raise httpbakery.InteractionError('no macaroon in response') - m = bakery.Macaroon.from_dict(m) - ms = bakery.discharge_all(m, None, self._auth_info.key) - b = bytearray() - for m in ms: - b.extend(utils.b64decode(m.serialize())) - return httpbakery.DischargeToken(kind='agent', value=bytes(b)) - - def _find_agent(self, location): - ''' Finds an appropriate agent entry for the given location. - :return Agent - ''' - for a in self._auth_info.agents: - # Don't worry about trailing slashes - if a.url.rstrip('/') == location.rstrip('/'): - return a - raise httpbakery.InteractionMethodNotFound( - 'cannot find username for discharge location {}'.format(location)) - - def legacy_interact(self, client, location, visit_url): - '''Implement LegacyInteractor.legacy_interact by obtaining - the discharge macaroon using the client's private key - ''' - agent = self._find_agent(location) - pk_encoded = self._auth_info.key.public_key.encode().decode('utf-8') - value = { - 'username': agent.username, - 'public_key': pk_encoded, - } - # TODO(rogpeppe) use client passed into interact method. - client = httpbakery.Client(key=self._auth_info.key) - client.cookies.set_cookie(utils.cookie( - url=visit_url, - name='agent-login', - value=base64.urlsafe_b64encode( - json.dumps(value).encode('utf-8')).decode('utf-8'), - )) - resp = requests.get(url=visit_url, cookies=client.cookies, auth=client.auth()) - if resp.status_code != 200: - raise httpbakery.InteractionError( - 'cannot acquire agent macaroon: {}'.format(resp.status_code)) - if not resp.json().get('agent-login', False): - raise httpbakery.InteractionError('agent login failed') - - -class Agent(namedtuple('Agent', 'url, username')): - ''' Represents an agent that can be used for agent authentication. - @param url holds the URL of the discharger that knows about the agent (string). - @param username holds the username agent (string). - ''' - - -class AuthInfo(namedtuple('AuthInfo', 'key, agents')): - ''' Holds the agent information required to set up agent authentication - information. - - It holds the agent's private key and information about the username - associated with each known agent-authentication server. - @param key the agent's private key (bakery.PrivateKey). - @param agents information about the known agents (list of Agent). - ''' diff --git a/macaroonbakery/httpbakery/browser.py b/macaroonbakery/httpbakery/browser.py deleted file mode 100644 index e3ce538..0000000 --- a/macaroonbakery/httpbakery/browser.py +++ /dev/null @@ -1,86 +0,0 @@ -# Copyright 2017 Canonical Ltd. -# Licensed under the LGPLv3, see LICENCE file for details. -import base64 -from collections import namedtuple -import requests -from six.moves.urllib.parse import urljoin - -from macaroonbakery.utils import visit_page_with_browser -from macaroonbakery.httpbakery.interactor import ( - Interactor, LegacyInteractor, WEB_BROWSER_INTERACTION_KIND, - DischargeToken -) -from macaroonbakery.httpbakery.error import InteractionError - - -class WebBrowserInteractor(Interactor, LegacyInteractor): - ''' Handles web-browser-based interaction-required errors by opening a - web browser to allow the user to prove their credentials interactively. - ''' - def __init__(self, open=visit_page_with_browser): - '''Create a WebBrowserInteractor that uses the given function - to open a browser window. The open function is expected to take - a single argument of string type, the URL to open. - ''' - self._open_web_browser = open - - def kind(self): - return WEB_BROWSER_INTERACTION_KIND - - def legacy_interact(self, ctx, location, visit_url): - '''Implement LegacyInteractor.legacy_interact by opening the - web browser window''' - self._open_web_browser(visit_url) - - def interact(self, ctx, location, ir_err): - '''Implement Interactor.interact by opening the browser window - and waiting for the discharge token''' - p = ir_err.interaction_method(self.kind(), WebBrowserInteractionInfo) - if not location.endswith('/'): - location += '/' - visit_url = urljoin(location, p.visit_url) - wait_token_url = urljoin(location, p.wait_token_url) - self._open_web_browser(visit_url) - return self._wait_for_token(ctx, wait_token_url) - - def _wait_for_token(self, ctx, wait_token_url): - ''' Returns a token from a the wait token URL - @param wait_token_url URL to wait for (string) - :return DischargeToken - ''' - resp = requests.get(wait_token_url) - if resp.status_code != 200: - raise InteractionError('cannot get {}'.format(wait_token_url)) - json_resp = resp.json() - kind = json_resp.get('kind') - if kind is None: - raise InteractionError( - 'cannot get kind token from {}'.format(wait_token_url)) - token_val = json_resp.get('token') - if token_val is None: - token_val = json_resp.get('token64') - if token_val is None: - raise InteractionError( - 'cannot get token from {}'.format(wait_token_url)) - token_val = base64.b64decode(token_val) - return DischargeToken(kind=kind, value=token_val) - - -class WebBrowserInteractionInfo(namedtuple('WebBrowserInteractionInfo', - 'visit_url, wait_token_url')): - ''' holds the information expected in the browser-window interaction - entry in an interaction-required error. - - :param visit_url holds the URL to be visited in a web browser. - :param wait_token_url holds a URL that will block on GET until the browser - interaction has completed. - ''' - @classmethod - def from_dict(cls, info_dict): - '''Create a new instance of WebBrowserInteractionInfo, as expected - by the Error.interaction_method method. - @param info_dict The deserialized JSON object - @return a new WebBrowserInteractionInfo object. - ''' - return WebBrowserInteractionInfo(visit_url=info_dict.get('VisitURL'), - wait_token_url=info_dict('WaitURL')) diff --git a/macaroonbakery/httpbakery/client.py b/macaroonbakery/httpbakery/client.py deleted file mode 100644 index b3036a1..0000000 --- a/macaroonbakery/httpbakery/client.py +++ /dev/null @@ -1,387 +0,0 @@ -# Copyright 2017 Canonical Ltd. -# Licensed under the LGPLv3, see LICENCE file for details. -import base64 -import json -import requests -from six.moves.http_cookies import SimpleCookie -from six.moves.urllib.parse import urljoin - -import macaroonbakery as bakery -import macaroonbakery.checkers as checkers -from macaroonbakery import utils -from macaroonbakery.httpbakery.interactor import ( - LegacyInteractor, - WEB_BROWSER_INTERACTION_KIND, -) -from macaroonbakery.httpbakery.error import ( - DischargeError, - ERR_DISCHARGE_REQUIRED, - ERR_INTERACTION_REQUIRED, - Error, - InteractionError, - InteractionMethodNotFound, -) -from macaroonbakery.httpbakery.error import BAKERY_PROTOCOL_HEADER -from macaroonbakery.httpbakery.browser import WebBrowserInteractor - -TIME_OUT = 30 -MAX_DISCHARGE_RETRIES = 3 - - -class BakeryException(requests.RequestException): - '''Raised when some errors happen using the httpbakery - authorizer''' - - -class Client: - '''Client holds the context for making HTTP requests with macaroons. - To make a request, use the auth method to obtain - an HTTP authorizer suitable for passing as the auth parameter - to a requests method. Note that the same cookie jar - should be passed to requests as is used to initialize - the client. - For example: - import macaroonbakery.httpbakery - client = httpbakery.Client() - resp = requests.get('some protected url', - cookies=client.cookies, - auth=client.auth()) - @param interaction_methods A list of Interactor implementations. - @param key The private key of the client {bakery.PrivateKey} - @param cookies storage for the cookies {CookieJar}. It should be the - same as in the requests cookies. If not provided, one - will be created. - ''' - def __init__(self, interaction_methods=None, key=None, cookies=None): - if interaction_methods is None: - interaction_methods = [WebBrowserInteractor()] - if cookies is None: - cookies = requests.cookies.RequestsCookieJar() - self._interaction_methods = interaction_methods - self._key = key - self.cookies = cookies - - def auth(self): - '''Return an authorizer object suitable for passing - to requests methods that accept one. - If a request returns a discharge-required error, - the authorizer will acquire discharge macaroons - and retry the request. - ''' - return _BakeryAuth(self) - - def request(self, method, url, **kwargs): - '''Use the requests library to make a request. - Using this method is like doing: - - requests.request(method, url, auth=client.auth()) - ''' - kwargs.setdefault('auth', self.auth()) - return requests.request(method=method, url=url, **kwargs) - - def handle_error(self, error, url): - '''Try to resolve the given error, which should be a response - to the given URL, by discharging any macaroon contained in - it. That is, if error.code is ERR_DISCHARGE_REQUIRED - then it will try to discharge err.info.macaroon. If the discharge - succeeds, the discharged macaroon will be saved to the client's cookie jar, - otherwise an exception will be raised. - ''' - if error.info is None or error.info.macaroon is None: - raise BakeryException('unable to read info in discharge error response') - - discharges = bakery.discharge_all( - error.info.macaroon, - self.acquire_discharge, - self._key, - ) - macaroons = '[' + ','.join(map(utils.macaroon_to_json_string, discharges)) + ']' - all_macaroons = base64.urlsafe_b64encode(utils.to_bytes(macaroons)) - - full_path = relative_url(url, error.info.macaroon_path) - if error.info.cookie_name_suffix is not None: - name = 'macaroon-' + error.info.cookie_name_suffix - else: - name = 'macaroon-auth' - expires = checkers.macaroons_expiry_time(checkers.Namespace(), discharges) - expires = None # TODO(rogpeppe) remove this line after fixing the tests. - self.cookies.set_cookie(utils.cookie( - name=name, - value=all_macaroons.decode('ascii'), - url=full_path, - expires=expires, - )) - - def acquire_discharge(self, cav, payload): - ''' Request a discharge macaroon from the caveat location - as an HTTP URL. - @param cav Third party {pymacaroons.Caveat} to be discharged. - @param payload External caveat data {bytes}. - @return The acquired macaroon {macaroonbakery.Macaroon} - ''' - resp = self._acquire_discharge_with_token(cav, payload, None) - # TODO Fabrice what is the other http response possible ?? - if resp.status_code == 200: - return bakery.Macaroon.from_dict(resp.json().get('Macaroon')) - cause = Error.from_dict(resp.json()) - if cause.code != ERR_INTERACTION_REQUIRED: - raise DischargeError(cause.message) - if cause.info is None: - raise DischargeError( - 'interaction-required response with no info: {}'.format(resp.json()) - ) - loc = cav.location - if not loc.endswith('/'): - loc = loc + '/' - token, m = self._interact(loc, cause, payload) - if m is not None: - # We've acquired the macaroon directly via legacy interaction. - return m - # Try to acquire the discharge again, but this time with - # the token acquired by the interaction method. - resp = self._acquire_discharge_with_token(cav, payload, token) - if resp.status_code == 200: - return bakery.Macaroon.deserialize_json( - resp.json().get('Macaroon')) - else: - raise DischargeError() - - def _acquire_discharge_with_token(self, cav, payload, token): - req = {} - _add_json_binary_field(cav.caveat_id_bytes, req, 'id') - if token is not None: - _add_json_binary_field(token.value, req, 'token') - req['token-kind'] = token.kind - if payload is not None: - req['caveat64'] = base64.urlsafe_b64encode(payload).rstrip( - b'=').decode('utf-8') - target = relative_url(cav.location, 'discharge') - headers = { - BAKERY_PROTOCOL_HEADER: str(bakery.LATEST_VERSION) - } - return self.request('POST', target, data=req, headers=headers) - - def _interact(self, location, error_info, payload): - '''Gathers a macaroon by directing the user to interact with a - web page. The error_info argument holds the interaction-required - error response. - @return DischargeToken, bakery.Macaroon - ''' - if self._interaction_methods is None or len(self._interaction_methods) == 0: - raise InteractionError('interaction required but not possible') - # TODO(rogpeppe) make the robust against a wider range of error info. - if error_info.info.interaction_methods is None and \ - error_info.info.visit_url is not None: - # It's an old-style error; deal with it differently. - return None, self._legacy_interact(location, error_info) - - for interactor in self._interaction_methods: - found = error_info.info.interaction_methods.get(interactor.kind()) - if found is None: - continue - try: - token = interactor.interact(self, location, error_info) - except InteractionMethodNotFound: - continue - if token is None: - raise InteractionError('interaction method returned an empty token') - return token, None - - raise InteractionError('no supported interaction method') - - def _legacy_interact(self, location, error_info): - visit_url = relative_url(location, error_info.info.visit_url) - wait_url = relative_url(location, error_info.info.wait_url) - method_urls = { - "interactive": visit_url - } - if len(self._interaction_methods) > 1 or \ - self._interaction_methods[0].kind() != WEB_BROWSER_INTERACTION_KIND: - # We have several possible methods or we only support a non-window - # method, so we need to fetch the possible methods supported by - # the discharger. - method_urls = _legacy_get_interaction_methods(visit_url) - - for interactor in self._interaction_methods: - kind = interactor.kind() - if kind == WEB_BROWSER_INTERACTION_KIND: - # This is the old name for browser-window interaction. - kind = "interactive" - - if not isinstance(interactor, LegacyInteractor): - # Legacy interaction mode isn't supported. - continue - - visit_url = method_urls.get(kind) - if visit_url is None: - continue - - visit_url = relative_url(location, visit_url) - interactor.legacy_interact(self, location, visit_url) - return _wait_for_macaroon(wait_url) - - raise InteractionError('no methods supported') - - -class _BakeryAuth: - '''_BakeryAuth implements an authorizer as required - by the requests HTTP client. - ''' - def __init__(self, client): - ''' - @param interaction_methods A list of Interactor implementations. - @param key The private key of the client (macaroonbakery.PrivateKey) - @param cookies storage for the cookies {CookieJar}. It should be the - same as in the requests cookies. - ''' - self._client = client - - def __call__(self, req): - req.headers[BAKERY_PROTOCOL_HEADER] = str(bakery.LATEST_VERSION) - hook = _prepare_discharge_hook(req.copy(), self._client) - req.register_hook(event='response', hook=hook) - return req - - -def _prepare_discharge_hook(req, client): - ''' Return the hook function (called when the response is received.) - - This allows us to intercept the response and do any necessary - macaroon discharge before returning. - ''' - class Retry: - # Define a local class so that we can use its class variable as - # mutable state accessed by the closures below. - count = 0 - - def hook(response, *args, **kwargs): - ''' Requests hooks system, this is the hook for the response. - ''' - status_code = response.status_code - - if status_code != 407 and status_code != 401: - return response - if (status_code == 401 and response.headers.get('WWW-Authenticate') != - 'Macaroon'): - return response - - if response.headers.get('Content-Type') != 'application/json': - return response - errorJSON = response.json() - if errorJSON.get('Code') != ERR_DISCHARGE_REQUIRED: - return response - error = Error.from_dict(errorJSON) - Retry.count += 1 - if Retry.count >= MAX_DISCHARGE_RETRIES: - raise BakeryException('too many ({}) discharge requests'.format( - Retry.count) - ) - client.handle_error(error, req.url) - # Replace the private _cookies from req as it is a copy of - # the original cookie jar passed into the requests method and we need - # to set the cookie for this request. - req._cookies = client.cookies - req.headers.pop('Cookie', None) - req.prepare_cookies(req._cookies) - req.headers[BAKERY_PROTOCOL_HEADER] = \ - str(bakery.LATEST_VERSION) - with requests.Session() as s: - return s.send(req) - return hook - - -def extract_macaroons(headers): - ''' Returns an array of any macaroons found in the given slice of cookies. - @param headers: dict of headers - @return: An array of array of mpy macaroons - ''' - mss = [] - - def add_macaroon(data): - data = utils.b64decode(data) - data_as_objs = json.loads(data.decode('utf-8')) - ms = [utils.macaroon_from_dict(x) for x in data_as_objs] - mss.append(ms) - - cookieHeader = headers.get('Cookie') - if cookieHeader is not None: - cs = SimpleCookie() - # The cookie might be a unicode object, so convert it - # to ASCII. This may cause an exception under Python 2. - # TODO is that a problem? - cs.load(str(cookieHeader)) - for c in cs: - if c.startswith('macaroon-'): - add_macaroon(cs[c].value) - # Python doesn't make it easy to have multiple values for a - # key, so split the header instead, which is necessary - # for HTTP1.1 compatibility anyway. - macaroonHeader = headers.get('Macaroons') - if macaroonHeader is not None: - for h in macaroonHeader.split(','): - add_macaroon(h) - return mss - - -def _add_json_binary_field(b, serialized, field): - '''' Set the given field to the given val (bytes) in the serialized - dictionary. - If the value isn't valid utf-8, we base64 encode it and use field+"64" - as the field name. - ''' - try: - val = b.decode('utf-8') - serialized[field] = val - except UnicodeDecodeError: - val = base64.b64encode(b).decode('utf-8') - serialized[field + '64'] = val - - -def _wait_for_macaroon(wait_url): - ''' Returns a macaroon from a legacy wait endpoint. - ''' - headers = { - BAKERY_PROTOCOL_HEADER: str(bakery.LATEST_VERSION) - } - resp = requests.get(url=wait_url, headers=headers) - if resp.status_code != 200: - return InteractionError('cannot get {}'.format(wait_url)) - - return bakery.Macaroon.from_dict(resp.json().get('Macaroon')) - - -def relative_url(base, new): - ''' Returns new path relative to an original URL. - ''' - if new == '': - return base - if not base.endswith('/'): - base += '/' - return urljoin(base, new) - - -def _legacy_get_interaction_methods(u): - ''' Queries a URL as found in an ErrInteractionRequired VisitURL field to - find available interaction methods. - It does this by sending a GET request to the URL with the Accept - header set to "application/json" and parsing the resulting - response as a dict. - ''' - headers = { - BAKERY_PROTOCOL_HEADER: str(bakery.LATEST_VERSION), - 'Accept': 'application/json' - } - resp = requests.get(url=u, headers=headers) - method_urls = {} - if resp.status_code == 200: - json_resp = resp.json() - for m in json_resp: - relative_url(u, json_resp[m]) - method_urls[m] = relative_url(u, json_resp[m]) - - if method_urls.get('interactive') is None: - # There's no "interactive" method returned, but we know - # the server does actually support it, because all dischargers - # are required to, so fill it in with the original URL. - method_urls['interactive'] = u - return method_urls diff --git a/macaroonbakery/httpbakery/discharge.py b/macaroonbakery/httpbakery/discharge.py deleted file mode 100644 index ef3481a..0000000 --- a/macaroonbakery/httpbakery/discharge.py +++ /dev/null @@ -1,33 +0,0 @@ -# Copyright 2017 Canonical Ltd. -# Licensed under the LGPLv3, see LICENCE file for details. -import macaroonbakery.utils as utils -import macaroonbakery as bakery - - -def discharge(ctx, content, key, locator, checker): - '''Handles a discharge request as received by the /discharge - endpoint. - @param ctx The context passed to the checker {checkers.AuthContext} - @param content URL and form parameters {dict} - @param locator Locator used to add third party caveats returned by - the checker {macaroonbakery.ThirdPartyLocator} - @param checker Used to check third party caveats {macaroonbakery.ThirdPartyCaveatChecker} - @return The discharge macaroon {macaroonbakery.Macaroon} - ''' - id = content.get('id') - if id is None: - id = content.get('id64') - if id is not None: - id = utils.b64decode(id) - caveat = content.get('caveat64') - if caveat is not None: - caveat = utils.b64decode(caveat) - - return bakery.discharge( - ctx, - id=id, - caveat=caveat, - key=key, - checker=checker, - locator=locator, - ) diff --git a/macaroonbakery/httpbakery/error.py b/macaroonbakery/httpbakery/error.py deleted file mode 100644 index 422b346..0000000 --- a/macaroonbakery/httpbakery/error.py +++ /dev/null @@ -1,200 +0,0 @@ -# Copyright 2017 Canonical Ltd. -# Licensed under the LGPLv3, see LICENCE file for details. -from collections import namedtuple -import json - -import macaroonbakery as bakery - -ERR_INTERACTION_REQUIRED = 'interaction required' -ERR_DISCHARGE_REQUIRED = 'macaroon discharge required' - - -class InteractionMethodNotFound(Exception): - '''This is thrown by client-side interaction methods when - they find that a given interaction isn't supported by the - client for a location''' - pass - - -class DischargeError(Exception): - '''This is thrown by Client when a third party has refused a discharge''' - def __init__(self, msg): - super(DischargeError, self).__init__('third party refused discharge: {}'.format(msg)) - - -class InteractionError(Exception): - '''This is thrown by Client when it fails to deal with an - interaction-required error - ''' - def __init__(self, msg): - super(InteractionError, self).__init__('cannot start interactive session: {}'.format(msg)) - - -def discharge_required_response(macaroon, path, cookie_suffix_name, - message=None): - ''' Get response content and headers from a discharge macaroons error. - - @param macaroon may hold a macaroon that, when discharged, may - allow access to a service. - @param path holds the URL path to be associated with the macaroon. - The macaroon is potentially valid for all URLs under the given path. - @param cookie_suffix_name holds the desired cookie name suffix to be - associated with the macaroon. The actual name used will be - ("macaroon-" + CookieName). Clients may ignore this field - - older clients will always use ("macaroon-" + macaroon.signature() in hex) - @return content(bytes) and the headers to set on the response(dict). - ''' - if message is None: - message = 'discharge required' - content = json.dumps( - { - 'Code': 'macaroon discharge required', - 'Message': message, - 'Info': { - 'Macaroon': macaroon.to_dict(), - 'MacaroonPath': path, - 'CookieNameSuffix': cookie_suffix_name - }, - } - ).encode('utf-8') - return content, { - 'WWW-Authenticate': 'Macaroon', - 'Content-Type': 'application/json' - } - -# BAKERY_PROTOCOL_HEADER is the header that HTTP clients should set -# to determine the bakery protocol version. If it is 0 or missing, -# a discharge-required error response will be returned with HTTP status 407; -# if it is greater than 0, the response will have status 401 with the -# WWW-Authenticate header set to "Macaroon". -BAKERY_PROTOCOL_HEADER = 'Bakery-Protocol-Version' - - -def request_version(req_headers): - ''' Determines the bakery protocol version from a client request. - If the protocol cannot be determined, or is invalid, the original version - of the protocol is used. If a later version is found, the latest known - version is used, which is OK because versions are backwardly compatible. - - @param req_headers: the request headers as a dict. - @return: bakery protocol version (for example macaroonbakery.VERSION_1) - ''' - vs = req_headers.get(BAKERY_PROTOCOL_HEADER) - if vs is None: - # No header - use backward compatibility mode. - return bakery.VERSION_1 - try: - x = int(vs) - except ValueError: - # Badly formed header - use backward compatibility mode. - return bakery.VERSION_1 - if x > bakery.LATEST_VERSION: - # Later version than we know about - use the - # latest version that we can. - return bakery.LATEST_VERSION - return x - - -class Error(namedtuple('Error', 'code, message, version, info')): - '''This class defines an error value as returned from - an httpbakery API. - ''' - @classmethod - def from_dict(cls, serialized): - '''Create an error from a JSON-deserialized object - @param serialized the object holding the serialized error {dict} - ''' - code = serialized.get('Code') - message = serialized.get('Message') - info = ErrorInfo.from_dict(serialized.get('Info')) - return Error(code=code, message=message, info=info, - version=bakery.LATEST_VERSION) - - def interaction_method(self, kind, x): - ''' Checks whether the error is an InteractionRequired error - that implements the method with the given name, and JSON-unmarshals the - method-specific data into x by calling its from_dict method - with the deserialized JSON object. - @param kind The interaction method kind (string). - @param x A class with a class method from_dict that returns a new - instance of the interaction info for the given kind. - @return The result of x.from_dict. - ''' - if self.info is None or self.code != ERR_INTERACTION_REQUIRED: - raise InteractionError( - 'not an interaction-required error (code {})'.format( - self.code) - ) - entry = self.info.interaction_methods.get(kind) - if entry is None: - raise InteractionMethodNotFound( - 'interaction method {} not found'.format(kind) - ) - return x.from_dict(entry) - - -class ErrorInfo( - namedtuple('ErrorInfo', 'macaroon, macaroon_path, cookie_name_suffix, ' - 'interaction_methods, visit_url, wait_url')): - ''' Holds additional information provided - by an error. - - @param macaroon may hold a macaroon that, when - discharged, may allow access to a service. - This field is associated with the ERR_DISCHARGE_REQUIRED - error code. - - @param macaroon_path holds the URL path to be associated - with the macaroon. The macaroon is potentially - valid for all URLs under the given path. - If it is empty, the macaroon will be associated with - the original URL from which the error was returned. - - @param cookie_name_suffix holds the desired cookie name suffix to be - associated with the macaroon. The actual name used will be - ("macaroon-" + cookie_name_suffix). Clients may ignore this field - - older clients will always use ("macaroon-" + - macaroon.signature() in hex). - - @param visit_url holds a URL that the client should visit - in a web browser to authenticate themselves. - - @param wait_url holds a URL that the client should visit - to acquire the discharge macaroon. A GET on - this URL will block until the client has authenticated, - and then it will return the discharge macaroon. - ''' - - __slots__ = () - - @classmethod - def from_dict(cls, serialized): - '''Create a new ErrorInfo object from a JSON deserialized - dictionary - @param serialized The JSON object {dict} - @return ErrorInfo object - ''' - if serialized is None: - return None - macaroon = serialized.get('Macaroon') - if macaroon is not None: - macaroon = bakery.Macaroon.from_dict(macaroon) - path = serialized.get('MacaroonPath') - cookie_name_suffix = serialized.get('CookieNameSuffix') - visit_url = serialized.get('VisitURL') - wait_url = serialized.get('WaitURL') - interaction_methods = serialized.get('InteractionMethods') - return ErrorInfo(macaroon=macaroon, macaroon_path=path, - cookie_name_suffix=cookie_name_suffix, - visit_url=visit_url, wait_url=wait_url, - interaction_methods=interaction_methods) - - def __new__(cls, macaroon=None, macaroon_path=None, - cookie_name_suffix=None, interaction_methods=None, - visit_url=None, wait_url=None): - '''Override the __new__ method so that we can - have optional arguments, which namedtuple doesn't - allow''' - return super(ErrorInfo, cls).__new__( - cls, macaroon, macaroon_path, cookie_name_suffix, - interaction_methods, visit_url, wait_url) diff --git a/macaroonbakery/httpbakery/interactor.py b/macaroonbakery/httpbakery/interactor.py deleted file mode 100644 index 0c15338..0000000 --- a/macaroonbakery/httpbakery/interactor.py +++ /dev/null @@ -1,73 +0,0 @@ -# Copyright 2017 Canonical Ltd. -# Licensed under the LGPLv3, see LICENCE file for details. -import abc -from collections import namedtuple - -WEB_BROWSER_INTERACTION_KIND = 'browser-window' - - -class Interactor(object): - ''' Represents a way of persuading a discharger that it should grant a - discharge macaroon. - ''' - __metaclass__ = abc.ABCMeta - - @abc.abstractmethod - def kind(self): - '''Returns the interaction method name. This corresponds to the key in - the Error.interaction_methods type. - @return {str} - ''' - raise NotImplementedError('kind method must be defined in ' - 'subclass') - - def interact(self, client, location, interaction_required_err): - ''' Performs the interaction, and returns a token that can be - used to acquire the discharge macaroon. The location provides - the third party caveat location to make it possible to use - relative URLs. The client holds the client being used to do the current - request. - - If the given interaction isn't supported by the client for - the given location, it may raise an InteractionMethodNotFound - which will cause the interactor to be ignored that time. - @param client The client being used for the current request {Client} - @param location Third party caveat location {str} - @param interaction_required_err The error causing the interaction to - take place {Error} - @return {DischargeToken} The discharge token. - ''' - raise NotImplementedError('interact method must be defined in ' - 'subclass') - - -class LegacyInteractor(object): - ''' May optionally be implemented by Interactor implementations that - implement the legacy interaction-required error protocols. - ''' - __metaclass__ = abc.ABCMeta - - @abc.abstractmethod - def legacy_interact(self, client, location, visit_url): - ''' Implements the "visit" half of a legacy discharge - interaction. The "wait" half will be implemented by httpbakery. - The location is the location specified by the third party - caveat. The client holds the client being used to do the current - request. - @param client The client being used for the current request {Client} - @param location Third party caveat location {str} - @param visit_url The visit_url field from the error {str} - @return None - ''' - raise NotImplementedError('legacy_interact method must be defined in ' - 'subclass') - - -class DischargeToken(namedtuple('DischargeToken', 'kind, value')): - ''' Holds a token that is intended to persuade a discharger to discharge - a third party caveat. - @param kind holds the kind of the token. By convention this - matches the name of the interaction method used to - obtain the token, but that's not required {str} - @param value holds the token data. {bytes} - ''' diff --git a/macaroonbakery/httpbakery/keyring.py b/macaroonbakery/httpbakery/keyring.py deleted file mode 100644 index 01a4349..0000000 --- a/macaroonbakery/httpbakery/keyring.py +++ /dev/null @@ -1,60 +0,0 @@ -# Copyright 2017 Canonical Ltd. -# Licensed under the LGPLv3, see LICENCE file for details. -from six.moves.urllib.parse import urlparse -import requests - -import macaroonbakery as bakery -from macaroonbakery.httpbakery.error import BAKERY_PROTOCOL_HEADER - - -class ThirdPartyLocator(bakery.ThirdPartyLocator): - ''' Implements macaroonbakery.ThirdPartyLocator by first looking in the - backing cache and, if that fails, making an HTTP request to find the - information associated with the given discharge location. - ''' - - def __init__(self, allow_insecure=False): - ''' - @param url: the url to retrieve public_key - @param allow_insecure: By default it refuses to use insecure URLs. - ''' - self._allow_insecure = allow_insecure - self._cache = {} - - def third_party_info(self, loc): - u = urlparse(loc) - if u.scheme != 'https' and not self._allow_insecure: - raise bakery.ThirdPartyInfoNotFound( - 'untrusted discharge URL {}'.format(loc)) - loc = loc.rstrip('/') - info = self._cache.get(loc) - if info is not None: - return info - url_endpoint = '/discharge/info' - headers = { - BAKERY_PROTOCOL_HEADER: str(bakery.LATEST_VERSION) - } - resp = requests.get(url=loc + url_endpoint, headers=headers) - status_code = resp.status_code - if status_code == 404: - url_endpoint = '/publickey' - resp = requests.get(url=loc + url_endpoint, headers=headers) - status_code = resp.status_code - if status_code != 200: - raise bakery.ThirdPartyInfoNotFound( - 'unable to get info from {}'.format(url_endpoint)) - json_resp = resp.json() - if json_resp is None: - raise bakery.ThirdPartyInfoNotFound( - 'no response from /discharge/info') - pk = json_resp.get('PublicKey') - if pk is None: - raise bakery.ThirdPartyInfoNotFound( - 'no public key found in /discharge/info') - idm_pk = bakery.PublicKey.deserialize(pk) - version = json_resp.get('Version', bakery.VERSION_1) - self._cache[loc] = bakery.ThirdPartyInfo( - version=version, - public_key=idm_pk - ) - return self._cache.get(loc) diff --git a/macaroonbakery/identity.py b/macaroonbakery/identity.py deleted file mode 100644 index 1579bba..0000000 --- a/macaroonbakery/identity.py +++ /dev/null @@ -1,126 +0,0 @@ -# Copyright 2017 Canonical Ltd. -# Licensed under the LGPLv3, see LICENCE file for details. -import abc - -import macaroonbakery as bakery - - -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 bakery.IdentityError('no identity declared or possible') diff --git a/macaroonbakery/internal/__init__.py b/macaroonbakery/internal/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/macaroonbakery/internal/id.proto b/macaroonbakery/internal/id.proto deleted file mode 100644 index eb3d614..0000000 --- a/macaroonbakery/internal/id.proto +++ /dev/null @@ -1,14 +0,0 @@ -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/internal/id_pb2.py b/macaroonbakery/internal/id_pb2.py deleted file mode 100644 index 0fd54c0..0000000 --- a/macaroonbakery/internal/id_pb2.py +++ /dev/null @@ -1,132 +0,0 @@ -# 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/keys.py b/macaroonbakery/keys.py deleted file mode 100644 index 5cf61c5..0000000 --- a/macaroonbakery/keys.py +++ /dev/null @@ -1,92 +0,0 @@ -# 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 encode(self, raw=False): - ''' Encode the key in a base64 format by default but when raw is True - it will return a an hex encoded bytes. - @return: bytes - ''' - if raw: - return self._key.encode() - return self._key.encode(nacl.encoding.Base64Encoder) - - 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 encode(self, raw=False): - ''' Encode the key in a base64 format by default but when raw is True - it will return a an hex encoded bytes. - @return: bytes - ''' - if raw: - return self._key.encode() - return self._key.encode(nacl.encoding.Base64Encoder) - - @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/macaroon.py b/macaroonbakery/macaroon.py deleted file mode 100644 index b745282..0000000 --- a/macaroonbakery/macaroon.py +++ /dev/null @@ -1,414 +0,0 @@ -# Copyright 2017 Canonical Ltd. -# Licensed under the LGPLv3, see LICENCE file for details. -import abc -import base64 -import json -import logging -import os - -import pymacaroons -from pymacaroons.serializers import json_serializer - -import macaroonbakery as bakery -import macaroonbakery.checkers as checkers -from macaroonbakery import utils - - -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=bakery.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 > bakery.LATEST_VERSION: - log.info('use last known version:{} instead of: {}'.format( - bakery.LATEST_VERSION, version - )) - version = bakery.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 = bakery.ThirdPartyInfo( - version=self._version, - public_key=info.public_key, - ) - - caveat_info = bakery.encode_caveat( - cav.condition, root_key, info, key, self._namespace) - if info.version < bakery.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 < bakery.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=bakery.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 < bakery.VERSION_3 or - version > bakery.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 = utils.b64decode(id64) - data = utils.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(bakery.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[:] - bakery.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 [bakery.VERSION_0, bakery.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 bakery.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 - - where 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 = bakery.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 = bakery.PublicKey.deserialize(fields[0]) - return bakery.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 bakery.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 bakery.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/oven.py b/macaroonbakery/oven.py deleted file mode 100644 index bf4bd27..0000000 --- a/macaroonbakery/oven.py +++ /dev/null @@ -1,268 +0,0 @@ -# Copyright 2017 Canonical Ltd. -# Licensed under the LGPLv3, see LICENCE file for details. - -import base64 -import hashlib -import itertools -import os - -import google -from pymacaroons import MACAROON_V2, Verifier -from pymacaroons.exceptions import ( - MacaroonUnmetCaveatException, MacaroonInvalidSignatureException -) -import six - -import macaroonbakery as bakery -import macaroonbakery.checkers as checkers -from macaroonbakery import utils -from macaroonbakery.internal import id_pb2 - - -class Oven: - ''' Oven bakes macaroons. They emerge sweet and delicious and ready for use - in a Checker. - - All macaroons are associated with one or more operations (see - the Op type) which define the capabilities of the macaroon. - - There is one special operation, "login" (defined by LOGIN_OP) which grants - the capability to speak for a particular user. - The login capability will never be mixed with other capabilities. - - It is up to the caller to decide on semantics for other operations. - ''' - - def __init__(self, key=None, location=None, locator=None, namespace=None, - root_keystore_for_ops=None, ops_store=None): - ''' - @param namespace holds the namespace to use when adding first party - caveats. - @param root_keystore_for_ops a function that will give the macaroon - storage to be used for root keys associated with macaroons created - with macaroon. - @param ops_store object is used to persistently store the association - of multi-op entities with their associated operations when macaroon is - called with multiple operations. - When this is in use, operation entities with the prefix "multi-" are - reserved - a "multi-"-prefixed entity represents a set of operations - stored in the OpsStore. - @param key holds the private nacl key pair used to encrypt third party - caveats. If it is None, no third party caveats can be created. - @param location string holds the location that will be associated with - new macaroons (as returned by Macaroon.Location). - @param locator is used to find out information on third parties when - adding third party caveats. If this is None, no non-local third - party caveats can be added. - ''' - self.key = key - self.location = location - self.locator = locator - if namespace is None: - namespace = checkers.Checker().namespace() - self.namespace = namespace - self.ops_store = ops_store - self.root_keystore_for_ops = root_keystore_for_ops - if root_keystore_for_ops is None: - my_store = bakery.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(bakery.LATEST_VERSION) + \ - id.SerializeToString() - - if bakery.macaroon_version(version) < MACAROON_V2: - # The old macaroon format required valid text for the macaroon id, - # so base64-encode it. - id_bytes = utils.raw_urlsafe_b64encode(id_bytes) - - m = bakery.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 bakery.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 bakery.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 = utils.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 == bakery.VERSION_2: - # Skip the UUID at the start of the id. - storage_id = id[1 + 16:] - if first == bakery.VERSION_3: - try: - id1 = id_pb2.MacaroonId.FromString(id[1:]) - except google.protobuf.message.DecodeError: - raise bakery.VerificationError( - 'no operations found in macaroon') - if len(id1.ops) == 0 or len(id1.ops[0].actions) == 0: - raise bakery.VerificationError( - 'no operations found in macaroon') - - ops = [] - for op in id1.ops: - for action in op.actions: - ops.append(bakery.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, [bakery.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/store.py b/macaroonbakery/store.py deleted file mode 100644 index ae5f7a7..0000000 --- a/macaroonbakery/store.py +++ /dev/null @@ -1,77 +0,0 @@ -# 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/tests/common.py b/macaroonbakery/tests/common.py index f238dfd..cfbfc52 100644 --- a/macaroonbakery/tests/common.py +++ b/macaroonbakery/tests/common.py @@ -2,10 +2,9 @@ # Licensed under the LGPLv3, see LICENCE file for details. from datetime import datetime, timedelta -import pytz - -import macaroonbakery as bakery +import macaroonbakery.bakery as bakery import macaroonbakery.checkers as checkers +import pytz class _StoppedClock(object): diff --git a/macaroonbakery/tests/test_agent.py b/macaroonbakery/tests/test_agent.py index 67f5b84..3b38337 100644 --- a/macaroonbakery/tests/test_agent.py +++ b/macaroonbakery/tests/test_agent.py @@ -1,27 +1,22 @@ # Copyright 2017 Canonical Ltd. # Licensed under the LGPLv3, see LICENCE file for details. -import base64 -from datetime import datetime, timedelta import json +import logging import os import tempfile +from datetime import datetime, timedelta from unittest import TestCase -import nacl.encoding -import requests.cookies -import six -from six.moves.urllib.parse import parse_qs -from six.moves.http_cookies import SimpleCookie -from httmock import ( - HTTMock, - urlmatch, - response -) - -import macaroonbakery as bakery -import macaroonbakery.httpbakery as httpbakery +import macaroonbakery.bakery as bakery import macaroonbakery.checkers as checkers +import macaroonbakery.httpbakery as httpbakery import macaroonbakery.httpbakery.agent as agent +import requests.cookies + +from httmock import HTTMock, response, urlmatch +from six.moves.urllib.parse import parse_qs + +log = logging.getLogger(__name__) class TestAgents(TestCase): @@ -44,73 +39,31 @@ class TestAgents(TestCase): os.remove(self.bad_key_agent_filename) os.remove(self.no_username_agent_filename) - def test_load_agents(self): - cookies, key = agent.load_agent_file(self.agent_filename) - self.assertEqual(key.encode(nacl.encoding.Base64Encoder), - b'CqoSgj06Zcgb4/S6RT4DpTjLAfKoznEY3JsShSjKJEU=') - self.assertEqual( - key.public_key.encode(nacl.encoding.Base64Encoder), - b'YAhRSsth3a36mRYqQGQaLiS4QJax0p356nd+B8x7UQE=') - - value = cookies.get('agent-login', domain='1.example.com') - jv = base64.b64decode(value) - if six.PY3: - jv = jv.decode('utf-8') - data = json.loads(jv) - self.assertEqual(data['username'], 'user-1') - self.assertEqual(data['public_key'], - 'YAhRSsth3a36mRYqQGQaLiS4QJax0p356nd+B8x7UQE=') - - value = cookies.get('agent-login', domain='2.example.com', - path='/discharger') - jv = base64.b64decode(value) - if six.PY3: - jv = jv.decode('utf-8') - data = json.loads(jv) - self.assertEqual(data['username'], 'user-2') - self.assertEqual(data['public_key'], - 'YAhRSsth3a36mRYqQGQaLiS4QJax0p356nd+B8x7UQE=') - - def test_load_agents_into_cookies(self): - cookies = requests.cookies.RequestsCookieJar() - c1, key = agent.load_agent_file( - self.agent_filename, - cookies=cookies, - ) - self.assertEqual(c1, cookies) - self.assertEqual( - key.encode(nacl.encoding.Base64Encoder), - b'CqoSgj06Zcgb4/S6RT4DpTjLAfKoznEY3JsShSjKJEU=', - ) - self.assertEqual( - key.public_key.encode(nacl.encoding.Base64Encoder), - b'YAhRSsth3a36mRYqQGQaLiS4QJax0p356nd+B8x7UQE=', - ) - - value = cookies.get('agent-login', domain='1.example.com') - jv = base64.b64decode(value) - if six.PY3: - jv = jv.decode('utf-8') - data = json.loads(jv) - self.assertEqual(data['username'], 'user-1') - self.assertEqual(data['public_key'], 'YAhRSsth3a36mRYqQGQaLiS4QJax0p356nd+B8x7UQE=') - - value = cookies.get('agent-login', domain='2.example.com', - path='/discharger') - jv = base64.b64decode(value) - if six.PY3: - jv = jv.decode('utf-8') - data = json.loads(jv) - self.assertEqual(data['username'], 'user-2') - self.assertEqual(data['public_key'], 'YAhRSsth3a36mRYqQGQaLiS4QJax0p356nd+B8x7UQE=') - - def test_load_agents_with_bad_key(self): + def test_load_auth_info(self): + auth_info = agent.load_auth_info(self.agent_filename) + self.assertEqual(str(auth_info.key), 'CqoSgj06Zcgb4/S6RT4DpTjLAfKoznEY3JsShSjKJEU=') + self.assertEqual(str(auth_info.key.public_key), 'YAhRSsth3a36mRYqQGQaLiS4QJax0p356nd+B8x7UQE=') + self.assertEqual(auth_info.agents, [ + agent.Agent(url='https://1.example.com/', username='user-1'), + agent.Agent(url='https://2.example.com/discharger', username='user-2'), + agent.Agent(url='http://0.3.2.1', username='test-user'), + ]) + + def test_invalid_agent_json(self): + with self.assertRaises(agent.AgentFileFormatError): + agent.read_auth_info('}') + + def test_invalid_read_auth_info_arg(self): with self.assertRaises(agent.AgentFileFormatError): - agent.load_agent_file(self.bad_key_agent_filename) + agent.read_auth_info(0) - def test_load_agents_with_no_username(self): + def test_load_auth_info_with_bad_key(self): with self.assertRaises(agent.AgentFileFormatError): - agent.load_agent_file(self.no_username_agent_filename) + agent.load_auth_info(self.bad_key_agent_filename) + + def test_load_auth_info_with_no_username(self): + with self.assertRaises(agent.AgentFileFormatError): + agent.load_auth_info(self.no_username_agent_filename) def test_agent_login(self): discharge_key = bakery.generate_key() @@ -138,7 +91,8 @@ class TestAgents(TestCase): content='done') except bakery.PermissionDenied: caveats = [ - checkers.Caveat(location='http://0.3.2.1', condition='is-ok') + checkers.Caveat(location='http://0.3.2.1', + condition='is-ok') ] m = server_bakery.oven.macaroon( version=bakery.LATEST_VERSION, @@ -177,11 +131,11 @@ class TestAgents(TestCase): return { 'status_code': 200, 'content': { - 'Macaroon': m.serialize_json() + 'Macaroon': m.to_dict() } } - key = bakery.generate_key() + auth_info = agent.load_auth_info(self.agent_filename) @urlmatch(path='.*/login') def login(url, request): @@ -190,7 +144,7 @@ class TestAgents(TestCase): version=bakery.LATEST_VERSION, expiry=datetime.utcnow() + timedelta(days=1), caveats=[bakery.local_third_party_caveat( - key.public_key, + auth_info.key.public_key, version=httpbakery.request_version(request.headers))], ops=[bakery.Op(entity='agent', action='login')]) return { @@ -204,17 +158,7 @@ class TestAgents(TestCase): HTTMock(discharge), \ HTTMock(login): client = httpbakery.Client(interaction_methods=[ - agent.AgentInteractor( - agent.AuthInfo( - key=key, - agents=[ - agent.Agent( - username='test-user', - url=u'http://0.3.2.1' - ) - ], - ), - ), + agent.AgentInteractor(auth_info), ]) resp = requests.get( 'http://0.1.2.3/here', @@ -315,25 +259,26 @@ class TestAgents(TestCase): key = bakery.generate_key() - @urlmatch(path='.*/visit?$') + @urlmatch(path='.*/visit') def visit(url, request): if request.headers.get('Accept') == 'application/json': return { 'status_code': 200, 'content': { - 'agent': request.url + 'agent': '/agent-visit', } } - cs = SimpleCookie() - cookies = request.headers.get('Cookie') - if cookies is not None: - cs.load(str(cookies)) - public_key = None - for c in cs: - if c == 'agent-login': - json_cookie = json.loads( - base64.b64decode(cs[c].value).decode('utf-8')) - public_key = bakery.PublicKey.deserialize(json_cookie.get('public_key')) + raise Exception('unexpected call to visit without Accept header') + + @urlmatch(path='.*/agent-visit') + def agent_visit(url, request): + if request.method != "POST": + raise Exception('unexpected method') + log.info('agent_visit url {}'.format(url)) + body = json.loads(request.body.decode('utf-8')) + if body['username'] != 'test-user': + raise Exception('unexpected username in body {!r}'.format(request.body)) + public_key = bakery.PublicKey.deserialize(body['public_key']) ms = httpbakery.extract_macaroons(request.headers) if len(ms) == 0: b = bakery.Bakery(key=discharge_key) @@ -356,11 +301,11 @@ class TestAgents(TestCase): return { 'status_code': 200, 'content': { - 'agent-login': True + 'agent_login': True } } - @urlmatch(path='.*/wait?$') + @urlmatch(path='.*/wait$') def wait(url, request): class EmptyChecker(bakery.ThirdPartyCaveatChecker): def check_third_party_caveat(self, ctx, info): @@ -385,12 +330,14 @@ class TestAgents(TestCase): with HTTMock(server_get), \ HTTMock(discharge), \ HTTMock(visit), \ - HTTMock(wait): + HTTMock(wait), \ + HTTMock(agent_visit): client = httpbakery.Client(interaction_methods=[ agent.AgentInteractor( agent.AuthInfo( key=key, - agents=[agent.Agent(username='test-user', url=u'http://0.3.2.1')], + agents=[agent.Agent(username='test-user', + url=u'http://0.3.2.1')], ), ), ]) @@ -414,11 +361,13 @@ agent_file = ''' }, { "url": "https://2.example.com/discharger", "username": "user-2" + }, { + "url": "http://0.3.2.1", + "username": "test-user" }] } ''' - bad_key_agent_file = ''' { "key": { diff --git a/macaroonbakery/tests/test_authorizer.py b/macaroonbakery/tests/test_authorizer.py index f90d2b5..d5539b7 100644 --- a/macaroonbakery/tests/test_authorizer.py +++ b/macaroonbakery/tests/test_authorizer.py @@ -2,7 +2,7 @@ # Licensed under the LGPLv3, see LICENCE file for details. from unittest import TestCase -import macaroonbakery as bakery +import macaroonbakery.bakery as bakery import macaroonbakery.checkers as checkers diff --git a/macaroonbakery/tests/test_bakery.py b/macaroonbakery/tests/test_bakery.py index 5a13cff..a6c3e58 100644 --- a/macaroonbakery/tests/test_bakery.py +++ b/macaroonbakery/tests/test_bakery.py @@ -2,19 +2,11 @@ # Licensed under the LGPLv3, see LICENCE file for details. from unittest import TestCase +import macaroonbakery.httpbakery as httpbakery import requests +from mock import patch -from mock import ( - patch, -) - -from httmock import ( - HTTMock, - urlmatch, - response -) - -import macaroonbakery.httpbakery as httpbakery +from httmock import HTTMock, response, urlmatch ID_PATH = 'http://example.com/someprotecteurl' @@ -29,7 +21,7 @@ json_macaroon = { }, { u'cid': u'allow read-no-terms write' }, { - u'cid': u'time-before 2016-07-19T14:29:14.312669464Z' + u'cid': u'time-before 2158-07-19T14:29:14.312669464Z' }], u'location': u'charmstore', u'signature': u'52d17cb11f5c84d58441bc0ffd7cc396' @@ -41,7 +33,7 @@ discharge_token = [{ u'caveats': [{ u'cid': u'declared username someone' }, { - u'cid': u'time-before 2016-08-15T15:55:52.428319076Z' + u'cid': u'time-before 2158-08-15T15:55:52.428319076Z' }, { u'cid': u'origin ' }], @@ -57,7 +49,7 @@ discharged_macaroon = { }, { u'cid': u'declared username someone' }, { - u'cid': u'time-before 2016-07-19T15:55:52.432439055Z' + u'cid': u'time-before 2158-07-19T15:55:52.432439055Z' }], u'location': u'', u'signature': u'3513db5503ab17f9576760cd28' @@ -167,6 +159,17 @@ def wait_after_401(url, request): } +@urlmatch(path='.*/wait') +def wait_on_error(url, request): + return { + 'status_code': 500, + 'content': { + 'DischargeToken': discharge_token, + 'Macaroon': discharged_macaroon + } + } + + class TestBakery(TestCase): def assert_cookie_security(self, cookies, name, secure): @@ -185,12 +188,14 @@ class TestBakery(TestCase): auth=client.auth()) resp.raise_for_status() assert 'macaroon-test' in client.cookies.keys() - self.assert_cookie_security(client.cookies, 'macaroon-test', secure=False) + self.assert_cookie_security(client.cookies, 'macaroon-test', + secure=False) @patch('webbrowser.open') def test_407_then_401_on_discharge(self, mock_open): client = httpbakery.Client() - with HTTMock(first_407_then_200), HTTMock(discharge_401), HTTMock(wait_after_401): + with HTTMock(first_407_then_200), HTTMock(discharge_401), \ + HTTMock(wait_after_401): resp = requests.get( ID_PATH, cookies=client.cookies, @@ -200,6 +205,53 @@ class TestBakery(TestCase): mock_open.assert_called_once_with(u'http://example.com/visit', new=1) assert 'macaroon-test' in client.cookies.keys() + @patch('webbrowser.open') + def test_407_then_error_on_wait(self, mock_open): + client = httpbakery.Client() + with HTTMock(first_407_then_200), HTTMock(discharge_401),\ + HTTMock(wait_on_error): + with self.assertRaises(httpbakery.InteractionError) as exc: + requests.get( + ID_PATH, + cookies=client.cookies, + auth=client.auth(), + ) + self.assertEqual(str(exc.exception), + 'cannot start interactive session: cannot get ' + 'http://example.com/wait') + mock_open.assert_called_once_with(u'http://example.com/visit', new=1) + + def test_407_then_no_interaction_methods(self): + client = httpbakery.Client(interaction_methods=[]) + with HTTMock(first_407_then_200), HTTMock(discharge_401): + with self.assertRaises(httpbakery.InteractionError) as exc: + requests.get( + ID_PATH, + cookies=client.cookies, + auth=client.auth(), + ) + self.assertEqual(str(exc.exception), + 'cannot start interactive session: interaction ' + 'required but not possible') + + def test_407_then_unknown_interaction_methods(self): + class UnknownInteractor(httpbakery.Interactor): + def kind(self): + return 'unknown' + client = httpbakery.Client(interaction_methods=[UnknownInteractor()]) + with HTTMock(first_407_then_200), HTTMock(discharge_401): + with self.assertRaises(httpbakery.InteractionError) as exc: + requests.get( + ID_PATH, + cookies=client.cookies, + auth=client.auth(), + ) + self.assertEqual( + str(exc.exception), + 'cannot start interactive session: no methods supported; ' + 'supported [unknown]; provided [interactive]' + ) + def test_cookie_with_port(self): client = httpbakery.Client() with HTTMock(first_407_then_200_with_port): @@ -219,4 +271,5 @@ class TestBakery(TestCase): auth=client.auth()) resp.raise_for_status() assert 'macaroon-test' in client.cookies.keys() - self.assert_cookie_security(client.cookies, 'macaroon-test', secure=True) + self.assert_cookie_security(client.cookies, 'macaroon-test', + secure=True) diff --git a/macaroonbakery/tests/test_checker.py b/macaroonbakery/tests/test_checker.py index 643c756..6b61768 100644 --- a/macaroonbakery/tests/test_checker.py +++ b/macaroonbakery/tests/test_checker.py @@ -1,17 +1,16 @@ # Copyright 2017 Canonical Ltd. # Licensed under the LGPLv3, see LICENCE file for details. import base64 -from collections import namedtuple import json -from unittest import TestCase +from collections import namedtuple from datetime import timedelta +from unittest import TestCase -from pymacaroons.verifier import Verifier, FirstPartyCaveatVerifierDelegate -import pymacaroons - -import macaroonbakery as bakery +import macaroonbakery.bakery as bakery import macaroonbakery.checkers as checkers -from macaroonbakery.tests.common import test_context, epoch, test_checker +import pymacaroons +from macaroonbakery.tests.common import epoch, test_checker, test_context +from pymacaroons.verifier import FirstPartyCaveatVerifierDelegate, Verifier class TestChecker(TestCase): @@ -53,7 +52,8 @@ class TestChecker(TestCase): client = _Client(locator) ctx = test_context.with_value(_DISCHARGE_USER_KEY, 'bob') - auth_info = client.do(ctx, ts, [bakery.Op(entity='something', action='read')]) + auth_info = client.do(ctx, ts, [bakery.Op(entity='something', + action='read')]) self.assertEqual(self._discharges, [_DischargeRecord(location='ids', user='bob')]) self.assertIsNotNone(auth_info) @@ -98,7 +98,8 @@ class TestChecker(TestCase): self.assertIsNotNone(auth_info) self.assertIsNone(auth_info.identity) self.assertEqual(len(auth_info.macaroons), 1) - self.assertEqual(auth_info.macaroons[0][0].identifier_bytes, m[0].identifier_bytes) + self.assertEqual(auth_info.macaroons[0][0].identifier_bytes, + m[0].identifier_bytes) def test_capability_multiple_entities(self): locator = _DischargerLocator() @@ -168,8 +169,10 @@ class TestChecker(TestCase): self.assertIsNotNone(auth_info) self.assertIsNone(auth_info.identity) self.assertEqual(len(auth_info.macaroons), 2) - self.assertEqual(auth_info.macaroons[0][0].identifier_bytes, m1[0].identifier_bytes) - self.assertEqual(auth_info.macaroons[1][0].identifier_bytes, m2[0].identifier_bytes) + self.assertEqual(auth_info.macaroons[0][0].identifier_bytes, + m1[0].identifier_bytes) + self.assertEqual(auth_info.macaroons[1][0].identifier_bytes, + m2[0].identifier_bytes) def test_combine_capabilities(self): locator = _DischargerLocator() @@ -560,8 +563,10 @@ class TestChecker(TestCase): # Try them the other way around and we should authenticate as alice. client3 = _Client(locator) - client3.add_macaroon(ts, '1.alice', client2._macaroons[ts.name()]['authn']) - client3.add_macaroon(ts, '2.bob', client1._macaroons[ts.name()]['authn']) + client3.add_macaroon(ts, '1.alice', + client2._macaroons[ts.name()]['authn']) + client3.add_macaroon(ts, '2.bob', + client1._macaroons[ts.name()]['authn']) auth_info = client3.do(test_context, ts, [bakery.LOGIN_OP]) self.assertEqual(auth_info.identity.id(), 'alice') @@ -890,7 +895,8 @@ class _BasicAuthIdService(bakery.IdentityClient): return bakery.SimpleIdentity(user), None def declared_identity(self, ctx, declared): - raise bakery.IdentityError('no identity declarations in basic auth id service') + raise bakery.IdentityError('no identity declarations in basic auth' + ' id service') _BASIC_AUTH_KEY = checkers.ContextKey('user-key') diff --git a/macaroonbakery/tests/test_checkers.py b/macaroonbakery/tests/test_checkers.py index f552fa4..28da06e 100644 --- a/macaroonbakery/tests/test_checkers.py +++ b/macaroonbakery/tests/test_checkers.py @@ -3,11 +3,10 @@ from datetime import datetime, timedelta from unittest import TestCase -import six -import pytz -from pymacaroons import Macaroon, MACAROON_V2 - import macaroonbakery.checkers as checkers +import pytz +import six +from pymacaroons import MACAROON_V2, Macaroon # A frozen time for the tests. NOW = datetime( diff --git a/macaroonbakery/tests/test_client.py b/macaroonbakery/tests/test_client.py index e1a4009..ab20c3b 100644 --- a/macaroonbakery/tests/test_client.py +++ b/macaroonbakery/tests/test_client.py @@ -3,23 +3,24 @@ import base64 import datetime import json -from unittest import TestCase -try: - from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler -except ImportError: - from http.server import HTTPServer, BaseHTTPRequestHandler import threading +from unittest import TestCase -from httmock import ( - HTTMock, - urlmatch -) +import macaroonbakery.bakery as bakery +import macaroonbakery.checkers as checkers +import macaroonbakery.httpbakery as httpbakery +import pymacaroons import requests +import macaroonbakery._utils as utils + +from httmock import HTTMock, urlmatch from six.moves.urllib.parse import parse_qs +from six.moves.urllib.request import Request -import macaroonbakery as bakery -import macaroonbakery.httpbakery as httpbakery -import macaroonbakery.checkers as checkers +try: + from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler +except ImportError: + from http.server import HTTPServer, BaseHTTPRequestHandler AGES = datetime.datetime.utcnow() + datetime.timedelta(days=1) TEST_OP = bakery.Op(entity='test', action='test') @@ -30,7 +31,7 @@ class TestClient(TestCase): b = new_bakery('loc', None, None) def handler(*args): - GetHandler(b, None, None, None, None, *args) + GetHandler(b, None, None, None, None, AGES, *args) try: httpd = HTTPServer(('', 0), handler) thread = threading.Thread(target=httpd.serve_forever) @@ -58,7 +59,7 @@ class TestClient(TestCase): b = new_bakery('loc', None, None) def handler(*args): - GetHandler(b, None, None, None, None, *args) + GetHandler(b, None, None, None, None, AGES, *args) try: httpd = HTTPServer(('', 0), handler) thread = threading.Thread(target=httpd.serve_forever) @@ -81,7 +82,7 @@ class TestClient(TestCase): finally: httpd.shutdown() - def test_repeated_request_with_body(self): + def test_expiry_cookie_is_set(self): class _DischargerLocator(bakery.ThirdPartyLocator): def __init__(self): self.key = bakery.generate_key() @@ -100,7 +101,8 @@ class TestClient(TestCase): def discharge(url, request): qs = parse_qs(request.body) content = {q: qs[q][0] for q in qs} - m = httpbakery.discharge(checkers.AuthContext(), content, d.key, d, alwaysOK3rd) + m = httpbakery.discharge(checkers.AuthContext(), content, d.key, d, + alwaysOK3rd) return { 'status_code': 200, 'content': { @@ -108,8 +110,10 @@ class TestClient(TestCase): } } + ages = datetime.datetime.utcnow() + datetime.timedelta(days=1) + def handler(*args): - GetHandler(b, 'http://1.2.3.4', None, None, None, *args) + GetHandler(b, 'http://1.2.3.4', None, None, None, ages, *args) try: httpd = HTTPServer(('', 0), handler) thread = threading.Thread(target=httpd.serve_forever) @@ -122,10 +126,64 @@ class TestClient(TestCase): cookies=client.cookies, auth=client.auth()) resp.raise_for_status() + m = bakery.Macaroon.from_dict(json.loads( + base64.b64decode(client.cookies.get('macaroon-test')).decode('utf-8'))[0]) + t = checkers.macaroons_expiry_time( + checkers.Namespace(), [m.macaroon]) + self.assertEquals(ages, t) self.assertEquals(resp.text, 'done') finally: httpd.shutdown() + def test_expiry_cookie_set_in_past(self): + class _DischargerLocator(bakery.ThirdPartyLocator): + def __init__(self): + self.key = bakery.generate_key() + + def third_party_info(self, loc): + if loc == 'http://1.2.3.4': + return bakery.ThirdPartyInfo( + public_key=self.key.public_key, + version=bakery.LATEST_VERSION, + ) + + d = _DischargerLocator() + b = new_bakery('loc', d, None) + + @urlmatch(path='.*/discharge') + def discharge(url, request): + qs = parse_qs(request.body) + content = {q: qs[q][0] for q in qs} + m = httpbakery.discharge(checkers.AuthContext(), content, d.key, d, + alwaysOK3rd) + return { + 'status_code': 200, + 'content': { + 'Macaroon': m.to_dict() + } + } + + ages = datetime.datetime.utcnow() - datetime.timedelta(days=1) + + def handler(*args): + GetHandler(b, 'http://1.2.3.4', None, None, None, ages, *args) + try: + httpd = HTTPServer(('', 0), handler) + thread = threading.Thread(target=httpd.serve_forever) + thread.start() + client = httpbakery.Client() + with HTTMock(discharge): + with self.assertRaises(httpbakery.BakeryException) as ctx: + requests.get( + url='http://' + httpd.server_address[0] + ':' + + str(httpd.server_address[1]), + cookies=client.cookies, + auth=client.auth()) + self.assertEqual(ctx.exception.args[0], + 'too many (3) discharge requests') + finally: + httpd.shutdown() + def test_too_many_discharge(self): class _DischargerLocator(bakery.ThirdPartyLocator): def __init__(self): @@ -155,7 +213,7 @@ class TestClient(TestCase): } def handler(*args): - GetHandler(b, 'http://1.2.3.4', None, None, None, *args) + GetHandler(b, 'http://1.2.3.4', None, None, None, AGES, *args) try: httpd = HTTPServer(('', 0), handler) thread = threading.Thread(target=httpd.serve_forever) @@ -199,7 +257,7 @@ class TestClient(TestCase): ThirdPartyCaveatCheckerF(check)) def handler(*args): - GetHandler(b, 'http://1.2.3.4', None, None, None, *args) + GetHandler(b, 'http://1.2.3.4', None, None, None, AGES, *args) try: httpd = HTTPServer(('', 0), handler) thread = threading.Thread(target=httpd.serve_forever) @@ -244,7 +302,7 @@ class TestClient(TestCase): } def handler(*args): - GetHandler(b, 'http://1.2.3.4', None, None, None, *args) + GetHandler(b, 'http://1.2.3.4', None, None, None, AGES, *args) try: httpd = HTTPServer(('', 0), handler) @@ -273,11 +331,34 @@ class TestClient(TestCase): finally: httpd.shutdown() + def test_extract_macaroons_from_request(self): + def encode_macaroon(m): + macaroons = '[' + utils.macaroon_to_json_string(m) + ']' + return base64.urlsafe_b64encode(utils.to_bytes(macaroons)).decode('ascii') + + req = Request('http://example.com') + m1 = pymacaroons.Macaroon(version=pymacaroons.MACAROON_V2, identifier='one') + req.add_header('Macaroons', encode_macaroon(m1)) + m2 = pymacaroons.Macaroon(version=pymacaroons.MACAROON_V2, identifier='two') + jar = requests.cookies.RequestsCookieJar() + jar.set_cookie(utils.cookie( + name='macaroon-auth', + value=encode_macaroon(m2), + url='http://example.com', + )) + jar.add_cookie_header(req) + + macaroons = httpbakery.extract_macaroons(req) + self.assertEquals(len(macaroons), 2) + macaroons.sort(key=lambda ms: ms[0].identifier) + self.assertEquals(macaroons[0][0].identifier, m1.identifier) + self.assertEquals(macaroons[1][0].identifier, m2.identifier) + class GetHandler(BaseHTTPRequestHandler): '''A mock HTTP server that serves a GET request''' def __init__(self, bakery, auth_location, mutate_error, - caveats, version, *args): + caveats, version, expiry, *args): ''' @param bakery used to check incoming requests and macaroons for discharge-required errors. @@ -288,14 +369,17 @@ class GetHandler(BaseHTTPRequestHandler): discharge-required error before responding to the client. @param caveats called to get caveats to add to the returned macaroon. - @param holds the version of the bakery that the + @param version holds the version of the bakery that the server will purport to serve. + @param expiry holds the expiry for the macaroon that will be created + in _write_discharge_error ''' self._bakery = bakery self._auth_location = auth_location self._mutate_error = mutate_error self._caveats = caveats self._server_version = version + self._expiry = expiry BaseHTTPRequestHandler.__init__(self, *args) def do_GET(self): @@ -333,7 +417,7 @@ class GetHandler(BaseHTTPRequestHandler): caveats.extend(self._caveats) m = self._bakery.oven.macaroon( - version=bakery.LATEST_VERSION, expiry=AGES, + version=bakery.LATEST_VERSION, expiry=self._expiry, caveats=caveats, ops=[TEST_OP]) content, headers = httpbakery.discharge_required_response( diff --git a/macaroonbakery/tests/test_codec.py b/macaroonbakery/tests/test_codec.py index d4fbc57..d82a794 100644 --- a/macaroonbakery/tests/test_codec.py +++ b/macaroonbakery/tests/test_codec.py @@ -3,12 +3,11 @@ import base64 from unittest import TestCase +import macaroonbakery.bakery as bakery +import macaroonbakery.checkers as checkers import nacl.public import six - -import macaroonbakery as bakery -from macaroonbakery import codec -import macaroonbakery.checkers as checkers +from macaroonbakery.bakery import _codec as codec class TestCodec(TestCase): diff --git a/macaroonbakery/tests/test_discharge.py b/macaroonbakery/tests/test_discharge.py index 433483a..27bae63 100644 --- a/macaroonbakery/tests/test_discharge.py +++ b/macaroonbakery/tests/test_discharge.py @@ -2,11 +2,10 @@ # Licensed under the LGPLv3, see LICENCE file for details. import unittest -from pymacaroons import MACAROON_V1, Macaroon - -import macaroonbakery as bakery +import macaroonbakery.bakery as bakery import macaroonbakery.checkers as checkers from macaroonbakery.tests import common +from pymacaroons import MACAROON_V1, Macaroon class TestDischarge(unittest.TestCase): diff --git a/macaroonbakery/tests/test_discharge_all.py b/macaroonbakery/tests/test_discharge_all.py index 7999f5f..cab8a07 100644 --- a/macaroonbakery/tests/test_discharge_all.py +++ b/macaroonbakery/tests/test_discharge_all.py @@ -2,11 +2,10 @@ # Licensed under the LGPLv3, see LICENCE file for details. import unittest -from pymacaroons.verifier import Verifier - -import macaroonbakery as bakery +import macaroonbakery.bakery as bakery import macaroonbakery.checkers as checkers from macaroonbakery.tests import common +from pymacaroons.verifier import Verifier def always_ok(predicate): diff --git a/macaroonbakery/tests/test_keyring.py b/macaroonbakery/tests/test_keyring.py index 438ab1b..3503145 100644 --- a/macaroonbakery/tests/test_keyring.py +++ b/macaroonbakery/tests/test_keyring.py @@ -2,11 +2,11 @@ # Licensed under the LGPLv3, see LICENCE file for details. import unittest -from httmock import urlmatch, HTTMock - -import macaroonbakery as bakery +import macaroonbakery.bakery as bakery import macaroonbakery.httpbakery as httpbakery +from httmock import HTTMock, urlmatch + class TestKeyRing(unittest.TestCase): @@ -19,7 +19,7 @@ class TestKeyRing(unittest.TestCase): 'status_code': 200, 'content': { 'Version': bakery.LATEST_VERSION, - 'PublicKey': key.public_key.encode().decode('utf-8') + 'PublicKey': str(key.public_key), } } @@ -41,7 +41,7 @@ class TestKeyRing(unittest.TestCase): 'status_code': 200, 'content': { 'Version': bakery.LATEST_VERSION, - 'PublicKey': key.public_key.encode().decode('utf-8') + 'PublicKey': str(key.public_key), } } @@ -64,7 +64,7 @@ class TestKeyRing(unittest.TestCase): return { 'status_code': 200, 'content': { - 'PublicKey': key.public_key.encode().decode('utf-8') + 'PublicKey': str(key.public_key), } } @@ -79,7 +79,7 @@ class TestKeyRing(unittest.TestCase): def test_allow_insecure(self): kr = httpbakery.ThirdPartyLocator() - with self.assertRaises(bakery.error.ThirdPartyInfoNotFound): + with self.assertRaises(bakery.ThirdPartyInfoNotFound): kr.third_party_info('http://0.1.2.3/') def test_fallback(self): @@ -96,7 +96,7 @@ class TestKeyRing(unittest.TestCase): return { 'status_code': 200, 'content': { - 'PublicKey': key.public_key.encode().decode('utf-8') + 'PublicKey': str(key.public_key), } } diff --git a/macaroonbakery/tests/test_macaroon.py b/macaroonbakery/tests/test_macaroon.py index 93bbbb8..bcbbf80 100644 --- a/macaroonbakery/tests/test_macaroon.py +++ b/macaroonbakery/tests/test_macaroon.py @@ -3,13 +3,12 @@ import json from unittest import TestCase -import six -import pymacaroons -from pymacaroons import serializers - -import macaroonbakery as bakery +import macaroonbakery.bakery as bakery import macaroonbakery.checkers as checkers +import pymacaroons +import six from macaroonbakery.tests import common +from pymacaroons import serializers class TestMacaroon(TestCase): @@ -25,7 +24,8 @@ class TestMacaroon(TestCase): self.assertEquals(m.version, bakery.LATEST_VERSION) def test_add_first_party_caveat(self): - m = bakery.Macaroon('rootkey', 'some id', 'here', bakery.LATEST_VERSION) + m = bakery.Macaroon('rootkey', 'some id', 'here', + bakery.LATEST_VERSION) m.add_caveat(checkers.Caveat('test_condition')) caveats = m.first_party_caveats() self.assertEquals(len(caveats), 1) diff --git a/macaroonbakery/tests/test_oven.py b/macaroonbakery/tests/test_oven.py index ae235de..3c29767 100644 --- a/macaroonbakery/tests/test_oven.py +++ b/macaroonbakery/tests/test_oven.py @@ -1,11 +1,10 @@ # Copyright 2017 Canonical Ltd. # Licensed under the LGPLv3, see LICENCE file for details. -from unittest import TestCase - import copy from datetime import datetime, timedelta +from unittest import TestCase -import macaroonbakery as bakery +import macaroonbakery.bakery as bakery EPOCH = datetime(1900, 11, 17, 19, 00, 13, 0, None) AGES = EPOCH + timedelta(days=10) @@ -88,7 +87,8 @@ class TestOven(TestCase): ops_store=bakery.MemoryOpsStore()) ops = [] for i in range(30000): - ops.append(bakery.Op(entity='entity' + str(i), action='action' + str(i))) + ops.append(bakery.Op(entity='entity' + str(i), + action='action' + str(i))) m = test_oven.macaroon(bakery.LATEST_VERSION, AGES, None, ops) diff --git a/macaroonbakery/tests/test_store.py b/macaroonbakery/tests/test_store.py index 5afa7be..8a54f59 100644 --- a/macaroonbakery/tests/test_store.py +++ b/macaroonbakery/tests/test_store.py @@ -2,7 +2,7 @@ # Licensed under the LGPLv3, see LICENCE file for details. from unittest import TestCase -import macaroonbakery as bakery +import macaroonbakery.bakery as bakery class TestOven(TestCase): diff --git a/macaroonbakery/tests/test_time.py b/macaroonbakery/tests/test_time.py index 38826e1..2685e56 100644 --- a/macaroonbakery/tests/test_time.py +++ b/macaroonbakery/tests/test_time.py @@ -1,16 +1,15 @@ # Copyright 2017 Canonical Ltd. # Licensed under the LGPLv3, see LICENCE file for details. +from collections import namedtuple from datetime import timedelta from unittest import TestCase -from collections import namedtuple -import pyrfc3339 +import macaroonbakery.checkers as checkers import pymacaroons +import pyrfc3339 from pymacaroons import Macaroon -import macaroonbakery.checkers as checkers - -t1 = pyrfc3339.parse('2017-10-26T16:19:47.441402074Z') +t1 = pyrfc3339.parse('2017-10-26T16:19:47.441402074Z', produce_naive=True) t2 = t1 + timedelta(hours=1) t3 = t2 + timedelta(hours=1) @@ -118,9 +117,17 @@ class TestExpireTime(TestCase): ] for test in tests: print('test ', test.about) - t = checkers.macaroons_expiry_time(checkers.Namespace(), test.macaroons) + t = checkers.macaroons_expiry_time(checkers.Namespace(), + test.macaroons) self.assertEqual(t, test.expectTime) + def test_macaroons_expire_time_skips_third_party(self): + m1 = newMacaroon([checkers.time_before_caveat(t1).condition]) + m2 = newMacaroon() + m2.add_third_party_caveat('https://example.com', 'a-key', '123') + t = checkers.macaroons_expiry_time(checkers.Namespace(), [m1, m2]) + self.assertEqual(t1, t) + def newMacaroon(conds=[]): m = Macaroon(key='key', version=2) diff --git a/macaroonbakery/tests/test_utils.py b/macaroonbakery/tests/test_utils.py new file mode 100644 index 0000000..fcc8839 --- /dev/null +++ b/macaroonbakery/tests/test_utils.py @@ -0,0 +1,74 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. + +import json +from datetime import datetime +from unittest import TestCase + +import macaroonbakery.bakery as bakery +import pymacaroons +import pytz +from macaroonbakery._utils import cookie +from pymacaroons.serializers import json_serializer + + +class CookieTest(TestCase): + + def test_cookie_expires_naive(self): + timestamp = datetime.utcnow() + c = cookie('http://example.com', 'test', 'value', expires=timestamp) + self.assertEqual( + c.expires, int((timestamp - datetime(1970, 1, 1)).total_seconds())) + + def test_cookie_expires_with_timezone(self): + timestamp = datetime.now(pytz.UTC) + self.assertRaises( + ValueError, cookie, 'http://example.com', 'test', 'value', + expires=timestamp) + + +class TestB64Decode(TestCase): + def test_decode(self): + test_cases = [{ + 'about': 'empty string', + 'input': '', + 'expect': '', + }, { + 'about': 'standard encoding, padded', + 'input': 'Z29+IQ==', + 'expect': 'go~!', + }, { + 'about': 'URL encoding, padded', + 'input': 'Z29-IQ==', + 'expect': 'go~!', + }, { + 'about': 'standard encoding, not padded', + 'input': 'Z29+IQ', + 'expect': 'go~!', + }, { + 'about': 'URL encoding, not padded', + 'input': 'Z29-IQ', + 'expect': 'go~!', + }, { + 'about': 'standard encoding, not enough much padding', + 'input': 'Z29+IQ=', + 'expect_error': 'illegal base64 data at input byte 8', + }] + for test in test_cases: + if test.get('expect_error'): + with self.assertRaises(ValueError, msg=test['about']) as e: + bakery.b64decode(test['input']) + self.assertEqual(str(e.exception), 'Incorrect padding') + else: + self.assertEqual(bakery.b64decode(test['input']), test['expect'].encode('utf-8'), msg=test['about']) + + +class MacaroonToDictTest(TestCase): + def test_macaroon_to_dict(self): + m = pymacaroons.Macaroon( + key=b'rootkey', identifier=b'some id', location='here', version=2) + as_dict = bakery.macaroon_to_dict(m) + data = json.dumps(as_dict) + m1 = pymacaroons.Macaroon.deserialize(data, json_serializer.JsonSerializer()) + self.assertEqual(m1.signature, m.signature) + pymacaroons.Verifier().verify(m1, b'rootkey') diff --git a/macaroonbakery/third_party.py b/macaroonbakery/third_party.py deleted file mode 100644 index 91eacaf..0000000 --- a/macaroonbakery/third_party.py +++ /dev/null @@ -1,57 +0,0 @@ -# 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/utils.py b/macaroonbakery/utils.py deleted file mode 100644 index 43b0bf2..0000000 --- a/macaroonbakery/utils.py +++ /dev/null @@ -1,143 +0,0 @@ -# Copyright 2017 Canonical Ltd. -# Licensed under the LGPLv3, see LICENCE file for details. -import base64 -import json -import webbrowser -import six -import six.moves.http_cookiejar as http_cookiejar -from six.moves.urllib.parse import urlparse - -from pymacaroons import Macaroon -from pymacaroons.serializers import json_serializer - - -def to_bytes(s): - '''Return s as a bytes type, using utf-8 encoding if necessary. - @param s string or bytes - @return bytes - ''' - if isinstance(s, six.binary_type): - return s - if isinstance(s, six.string_types): - return s.encode('utf-8') - raise TypeError('want string or bytes, got {}', type(s)) - - -def macaroon_from_dict(json_macaroon): - '''Return a pymacaroons.Macaroon object from the given - JSON-deserialized dict. - - @param JSON-encoded macaroon as dict - @return the deserialized macaroon object. - ''' - return Macaroon.deserialize(json.dumps(json_macaroon), - json_serializer.JsonSerializer()) - - -def macaroon_to_json_string(macaroon): - '''Serialize macaroon object to a JSON-encoded string. - - @param macaroon object to be serialized. - @return a string serialization form of the macaroon. - ''' - return macaroon.serialize(json_serializer.JsonSerializer()) - - -def _add_base64_padding(b): - '''Add padding to base64 encoded bytes. - - pymacaroons does not give padded base64 bytes from serialization. - - @param bytes b to be padded. - @return a padded bytes. - ''' - return b + b'=' * (-len(b) % 4) - - -def _remove_base64_padding(b): - '''Remove padding from base64 encoded bytes. - - pymacaroons does not give padded base64 bytes from serialization. - - @param bytes b to be padded. - @return a padded bytes. - ''' - return b.rstrip(b'=') - - -def b64decode(s): - '''Base64 decodes a base64-encoded string in URL-safe - or normal format, with or without padding. - The argument may be string or bytes. - - @param s bytes decode - @return bytes decoded - ''' - # add padding if necessary. - s = to_bytes(s) - s = s + b'=' * (-len(s) % 4) - if '_' or '-' in s: - return base64.urlsafe_b64decode(s) - else: - return base64.b64decode(s) - - -def raw_urlsafe_b64encode(b): - '''Base64 encode using URL-safe encoding with padding removed. - - @param b bytes to decode - @return bytes decoded - ''' - b = to_bytes(b) - b = base64.urlsafe_b64encode(b) - b = b.rstrip(b'=') # strip padding - return b - - -def visit_page_with_browser(visit_url): - '''Open a browser so the user can validate its identity. - - @param visit_url: where to prove your identity. - ''' - webbrowser.open(visit_url, new=1) - print('Opening an authorization web page in your browser.') - print('If it does not open, please open this URL:\n', visit_url, '\n') - - -def cookie( - url, - name, - value, - expires=None): - '''Return a new Cookie using a slightly more - friendly API than that provided by six.moves.http_cookiejar - @param name The cookie name {str} - @param value The cookie value {str} - @param url The URL path of the cookie {str} - @param expires The expiry time of the cookie {datetime} - ''' - u = urlparse(url) - domain = u.hostname or u.netloc - port = str(u.port) if u.port is not None else None - secure = u.scheme == 'https' - if expires is not None: - expires = expires.strftime("%s") - return http_cookiejar.Cookie( - version=0, - name=name, - value=value, - port=port, - port_specified=port is not None, - domain=domain, - domain_specified=True, - domain_initial_dot=False, - path=u.path, - path_specified=True, - secure=secure, - expires=expires, - discard=False, - comment=None, - comment_url=None, - rest=None, - rfc2109=False, - ) diff --git a/macaroonbakery/versions.py b/macaroonbakery/versions.py deleted file mode 100644 index 7446d31..0000000 --- a/macaroonbakery/versions.py +++ /dev/null @@ -1,9 +0,0 @@ -# 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 -- cgit v1.2.3