diff options
Diffstat (limited to 'macaroonbakery/macaroon.py')
-rw-r--r-- | macaroonbakery/macaroon.py | 440 |
1 files changed, 265 insertions, 175 deletions
diff --git a/macaroonbakery/macaroon.py b/macaroonbakery/macaroon.py index 954161c..6f6039e 100644 --- a/macaroonbakery/macaroon.py +++ b/macaroonbakery/macaroon.py @@ -1,37 +1,30 @@ # Copyright 2017 Canonical Ltd. # Licensed under the LGPLv3, see LICENCE file for details. - +import abc import base64 -import copy +import json import logging import os -from macaroonbakery import bakery -from macaroonbakery import codec import pymacaroons +from pymacaroons.serializers import json_serializer -from macaroonbakery import namespace +import macaroonbakery +import macaroonbakery.checkers as checkers +from macaroonbakery import utils -MACAROON_V1, MACAROON_V2 = 1, 2 log = logging.getLogger(__name__) -def legacy_namespace(): - ''' Standard namespace for pre-version3 macaroons. - ''' - ns = namespace.Namespace(None) - ns.register(namespace.STD_NAMESPACE, '') - return ns - - -class Macaroon: - '''Represent an undischarged macaroon along its first +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_BAKERY_VERSION, ns=None): + version=macaroonbakery.LATEST_BAKERY_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, @@ -41,22 +34,45 @@ class Macaroon: @param id bytes or string @param location bytes or string @param version the bakery version. - @param ns + @param namespace is that of the service creating it ''' - if version > bakery.LATEST_BAKERY_VERSION: + if version > macaroonbakery.LATEST_BAKERY_VERSION: log.info('use last known version:{} instead of: {}'.format( - bakery.LATEST_BAKERY_VERSION, version + macaroonbakery.LATEST_BAKERY_VERSION, version )) - version = bakery.LATEST_BAKERY_VERSION + version = macaroonbakery.LATEST_BAKERY_VERSION # m holds the underlying macaroon. - self._macaroon = pymacaroons.Macaroon(location=location, key=root_key, - identifier=id) + self._macaroon = pymacaroons.Macaroon( + location=location, key=root_key, identifier=id, + version=macaroon_version(version)) # version holds the version of the macaroon. - self.version = macaroon_version(version) - self.caveat_data = {} + 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): - '''Return a new macaroon with the given caveat added. + '''Add a caveat to the macaroon. It encrypts it using the given key pair and by looking up the location using the given locator. @@ -68,96 +84,138 @@ class Macaroon: key. @param cav the checkers.Caveat to be added. - @param key the nacl public key to encrypt third party caveat. + @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. - @return a new macaroon object with the given caveat. ''' if cav.location is None: - macaroon = self._macaroon.add_first_party_caveat(cav.condition) - new_macaroon = copy.copy(self) - new_macaroon._macaroon = macaroon - return new_macaroon + 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, ok = parse_local_location(cav.location) - if ok: + local_info = _parse_local_location(cav.location) + if local_info is not None: info = local_info - cav.location = 'local' if cav.condition is not '': raise ValueError( 'cannot specify caveat condition in ' 'local third-party caveat') - cav.condition = 'true' + 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.version = self.version + if self._version < info.version: + info = macaroonbakery.ThirdPartyInfo(version=self._version, + public_key=info.public_key) - caveat_info = codec.encode_caveat(cav.condition, root_key, info, - key, None) - if info.version < bakery.BAKERY_V3: + caveat_info = macaroonbakery.encode_caveat( + cav.condition, root_key, info, key, self._namespace) + if info.version < macaroonbakery.BAKERY_V3: # 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 + id = self._new_caveat_id(self._caveat_id_prefix) + self._caveat_data[id] = caveat_info - m = self._macaroon.add_third_party_caveat(cav.location, root_key, id) - new_macaroon = copy.copy(self) - new_macaroon._macaroon = m - return new_macaroon + self._macaroon.add_third_party_caveat(cav.location, root_key, id) def add_caveats(self, cavs, key, loc): - '''Return a new macaroon with all caveats added. + '''Add an array of caveats to the macaroon. This method does not mutate the current object. @param cavs arrary of caveats. - @param key the nacl public key to encrypt third party caveat. + @param key the PublicKey to encrypt third party caveat. @param loc locator to find the location object that has a method third_party_info. - @return a new macaroon object with the given caveats. ''' - macaroon = self + if cavs is None: + return for cav in cavs: - macaroon = macaroon.add_caveat(cav, key, loc) - return macaroon + self.add_caveat(cav, key, loc) - def serialize(self): - '''Return a dictionary holding the macaroon data in V1 JSON format. - - Note that this differs from the underlying macaroon serialize method as - it does not return a string. This makes it easier to incorporate the - macaroon into other JSON objects. + 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()) - @return a dictionary holding the macaroon data - in V1 JSON format + def to_dict(self): + '''Return a dict representation of the macaroon data in JSON format. + @return a dict ''' - if self.version == bakery.BAKERY_V1: - # latest libmacaroons do not support the old format - json_macaroon = self._macaroon.serialize('json') - val = { - 'identifier': _field_v2(json_macaroon, 'i'), - 'signature': _field_v2(json_macaroon, 's'), - } - location = json_macaroon.get('l') - if location is not None: - val['location'] = location - cavs = json_macaroon.get('c') - if cavs is not None: - val['caveats'] = map(cavs, _cav_v2_to_v1) - return val - raise NotImplementedError('only bakery v1 supported') + if self.version < macaroonbakery.BAKERY_V3: + 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 deserialize_json(cls, serialized_json): + serialized = json.loads(serialized_json) + json_macaroon = serialized.get('m') + if json_macaroon is None: + # Try the v1 format if we don't have a macaroon filed + m = pymacaroons.Macaroon.deserialize( + serialized_json, json_serializer.JsonSerializer()) + macaroon = Macaroon(root_key=None, id=None, + namespace=macaroonbakery.legacy_namespace(), + version=_bakery_version(m.version)) + macaroon._macaroon = m + return macaroon + + version = serialized.get('v', None) + if version is None: + raise ValueError('no version specified') + if (version < macaroonbakery.BAKERY_V3 or + version > macaroonbakery.LATEST_BAKERY_VERSION): + raise ValueError('unknow 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(serialized.get('ns')) + cdata = serialized.get('cdata', {}) + caveat_data = {} + for id64 in cdata: + id = utils.raw_b64decode(id64) + data = utils.raw_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 def _new_caveat_id(self, base): '''Return a third party caveat id @@ -165,10 +223,44 @@ class Macaroon: 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 string - @return string + @param base bytes + @return bytes ''' - raise NotImplementedError + 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(macaroonbakery.BAKERY_V3) + + # 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[:] + macaroonbakery.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. @@ -185,6 +277,18 @@ class Macaroon: ''' 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. @@ -192,12 +296,50 @@ def macaroon_version(bakery_version): @param bakery_version the bakery version @return macaroon_version the derived macaroon version ''' - if bakery_version in [bakery.BAKERY_V0, bakery.BAKERY_V1]: - return MACAROON_V1 - return MACAROON_V2 + if bakery_version in [macaroonbakery.BAKERY_V0, macaroonbakery.BAKERY_V1]: + 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 macaroonbakery.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): +def _parse_local_location(loc): '''Parse a local caveat location as generated by LocalThirdPartyCaveat. This is of the form: @@ -207,105 +349,53 @@ def parse_local_location(loc): where <version> is the bakery version of the client that we're adding the local caveat for. - It returns false if the location does not represent a local + It returns None if the location does not represent a local caveat location. - @return a tuple of location and if the location is local. + @return a ThirdPartyInfo. ''' - if not(loc.startswith('local ')): - return (), False - v = bakery.BAKERY_V1 + if not (loc.startswith('local ')): + return None + v = macaroonbakery.BAKERY_V1 fields = loc.split() fields = fields[1:] # Skip 'local' if len(fields) == 2: try: v = int(fields[0]) except ValueError: - return (), False + return None fields = fields[1:] if len(fields) == 1: - return (base64.b64decode(fields[0]), v), True - return (), False - - -class ThirdPartyLocator: - '''Used to find information on third party discharge services. - ''' - def __init__(self): - self._store = {} - - def third_party_info(self, loc): - '''Return information on the third party at the given location. - - It returns None if no match is found. - - @param loc string - @return: string - ''' - return self._store.get(loc) - - def add_info(self, loc, info): - '''Associates the given information with the given location. - - It will ignore any trailing slash. - ''' - self._store[loc.rstrip('\\')] = info - - -class ThirdPartyCaveatInfo: - '''ThirdPartyCaveatInfo holds the information decoded from - a third party caveat id. - ''' - def __init__(self, condition, first_party_public_key, third_party_key_pair, - root_key, caveat, version, ns): - ''' - @param condition holds the third party condition to be discharged. - This is the only field that most third party dischargers will - need to consider. - @param first_party_public_key holds the nacl public key of the party - that created the third party caveat. - @param third_party_key_pair holds the nacl private used to decrypt - the caveat - the key pair of the discharging service. - @param root_key bytes holds the secret root key encoded by the caveat. - @param caveat holds the full encoded base64 string caveat id from - which all the other fields are derived. - @param version holds the version that was used to encode - the caveat id. - @params 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. - ''' - self.condition = condition, - self.first_party_public_key = first_party_public_key, - self.third_party_key_pair = third_party_key_pair, - self.root_key = root_key, - self.caveat = caveat, - self.version = version, - self.ns = ns - - def __eq__(self, other): - return ( - self.condition == other.condition and - self.first_party_public_key == other.first_party_public_key and - self.third_party_key_pair == other.third_party_key_pair and - self.caveat == other.caveat and - self.version == other.version and - self.ns == other.ns - ) - - -def _field_v2(dict, field): - val = dict.get(field) - if val is None: - return base64.b64decode(dict.get(field + '64')) - return val - - -def _cav_v2_to_v1(cav): - val = { - 'cid': _field_v2(cav, 'i'), - 'vid': _field_v2(cav, 'v') - } - location = cav.get('l') - if location is not None: - val['cl'] = location - return val + key = macaroonbakery.PublicKey.deserialize(fields[0]) + return macaroonbakery.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 macaroonbakery.BAKERY_V1 + elif v == pymacaroons.MACAROON_V2: + # Note that this could also correspond to Version 3, but + # this logic is explicitly for legacy versions. + return macaroonbakery.BAKERY_V2 + 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) |