From 79ff2842fa477ee0693ea167c0a74cd7cf080d27 Mon Sep 17 00:00:00 2001 From: Colin Watson Date: Tue, 31 Oct 2017 10:34:41 +0000 Subject: Import py-macaroon-bakery_0.0.3.orig.tar.gz --- macaroonbakery/__init__.py | 17 ++ macaroonbakery/bakery.py | 237 +++++++++++++++++++++++++ macaroonbakery/checkers.py | 23 +++ macaroonbakery/codec.py | 299 +++++++++++++++++++++++++++++++ macaroonbakery/httpbakery/__init__.py | 1 + macaroonbakery/httpbakery/agent.py | 53 ++++++ macaroonbakery/httpbakery/client.py | 157 +++++++++++++++++ macaroonbakery/json_serializer.py | 75 ++++++++ macaroonbakery/macaroon.py | 311 +++++++++++++++++++++++++++++++++ macaroonbakery/namespace.py | 115 ++++++++++++ macaroonbakery/tests/__init__.py | 0 macaroonbakery/tests/test_agent.py | 149 ++++++++++++++++ macaroonbakery/tests/test_bakery.py | 166 ++++++++++++++++++ macaroonbakery/tests/test_codec.py | 178 +++++++++++++++++++ macaroonbakery/tests/test_macaroon.py | 64 +++++++ macaroonbakery/tests/test_namespace.py | 58 ++++++ macaroonbakery/utils.py | 79 +++++++++ 17 files changed, 1982 insertions(+) create mode 100644 macaroonbakery/__init__.py create mode 100644 macaroonbakery/bakery.py create mode 100644 macaroonbakery/checkers.py create mode 100644 macaroonbakery/codec.py create mode 100644 macaroonbakery/httpbakery/__init__.py create mode 100644 macaroonbakery/httpbakery/agent.py create mode 100644 macaroonbakery/httpbakery/client.py create mode 100644 macaroonbakery/json_serializer.py create mode 100644 macaroonbakery/macaroon.py create mode 100644 macaroonbakery/namespace.py create mode 100644 macaroonbakery/tests/__init__.py create mode 100644 macaroonbakery/tests/test_agent.py create mode 100644 macaroonbakery/tests/test_bakery.py create mode 100644 macaroonbakery/tests/test_codec.py create mode 100644 macaroonbakery/tests/test_macaroon.py create mode 100644 macaroonbakery/tests/test_namespace.py create mode 100644 macaroonbakery/utils.py (limited to 'macaroonbakery') diff --git a/macaroonbakery/__init__.py b/macaroonbakery/__init__.py new file mode 100644 index 0000000..8020901 --- /dev/null +++ b/macaroonbakery/__init__.py @@ -0,0 +1,17 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. + +from __future__ import unicode_literals +try: + import urllib3.contrib.pyopenssl +except ImportError: + pass +else: + urllib3.contrib.pyopenssl.inject_into_urllib3() + +VERSION = (0, 0, 3) + + +def get_version(): + '''Return the macaroon bakery version as a string.''' + return '.'.join(map(str, VERSION)) diff --git a/macaroonbakery/bakery.py b/macaroonbakery/bakery.py new file mode 100644 index 0000000..a3fcf88 --- /dev/null +++ b/macaroonbakery/bakery.py @@ -0,0 +1,237 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. + +import base64 +from collections import namedtuple +import json +import requests +from macaroonbakery import utils + +import nacl.utils +from nacl.public import Box + +from pymacaroons import Macaroon + +ERR_INTERACTION_REQUIRED = 'interaction required' +ERR_DISCHARGE_REQUIRED = 'macaroon discharge required' +TIME_OUT = 30 +DEFAULT_PROTOCOL_VERSION = {'Bakery-Protocol-Version': '1'} +MAX_DISCHARGE_RETRIES = 3 + +BAKERY_V0 = 0 +BAKERY_V1 = 1 +BAKERY_V2 = 2 +BAKERY_V3 = 3 +LATEST_BAKERY_VERSION = BAKERY_V3 +NONCE_LEN = 24 + + +# A named tuple composed of the visit_url and wait_url coming from the error +# response in discharge +_Info = namedtuple('Info', 'visit_url wait_url') + + +class DischargeException(Exception): + '''A discharge error occurred.''' + + +def discharge_all(macaroon, visit_page=None, jar=None, key=None): + '''Gathers discharge macaroons for all the third party caveats in macaroon. + + All the discharge macaroons will be bound to the primary macaroon. + The 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 by a server to ask a client to prove ownership of the + private key. + @param macaroon The macaroon to be discharged. + @param visit_page function called when the discharge process requires + further interaction. + @param jar the storage for the cookies. + @param key optional nacl key. + @return An array with macaroon as the first element, followed by all the + discharge macaroons. + ''' + discharges = [macaroon] + if visit_page is None: + visit_page = utils.visit_page_with_browser + if jar is None: + jar = requests.cookies.RequestsCookieJar() + client = _Client(visit_page, jar) + try: + client.discharge_caveats(macaroon, discharges, macaroon, key) + except Exception as exc: + raise DischargeException('unable to discharge the macaroon', exc) + return discharges + + +def discharge(key, id, caveat=None, checker=None, locator=None): + '''Creates a macaroon to discharge a third party caveat. + + @param key nacl 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 id bytes holding 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 holding the encrypted third party caveat. + If this is None, id will be used + @param checker used to check the third party caveat, + and may also return further caveats to be added to + the discharge macaroon. object that will have a function + check_third_party_caveat taking a dict of third party caveat info + as parameter. + @param locator used to retrieve information on third parties + referred to by third party caveats returned by the checker. Object that + will have a third_party_info function taking a location as a string. + @return macaroon with third party caveat discharged. + ''' + if caveat is None: + caveat = id + cav_info = _decode_caveat(key, caveat) + return Macaroon(location='', key=cav_info['RootKey'], identifier=id) + + +class _Client: + def __init__(self, visit_page, jar): + self._visit_page = visit_page + self._jar = jar + + def discharge_caveats(self, macaroon, discharges, + primary_macaroon, key): + '''Gathers discharge macaroons for all the third party caveats. + + @param macaroon the macaroon to discharge. + @param discharges the list of discharged macaroons. + @param primary_macaroon used for the signature of the discharge + macaroon. + @param key nacl 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 + ''' + caveats = macaroon.third_party_caveats() + for caveat in caveats: + location = caveat.location + b_cav_id = caveat.caveat_id.encode('utf-8') + if key is not None and location == 'local': + # if tuple is only 2 element otherwise TODO add caveat + dm = discharge(key, id=b_cav_id) + else: + dm = self._get_discharge(location, b_cav_id) + dm = primary_macaroon.prepare_for_request(dm) + discharges.append(dm) + self.discharge_caveats(dm, discharges, primary_macaroon, key) + + def _get_discharge(self, third_party_location, + third_party_caveat_condition): + '''Get the discharge macaroon from the third party location. + + @param third_party_location where to get a discharge from. + @param third_party_caveat_condition encoded 64 string associated to the + discharged macaroon. + @return a discharge macaroon. + @raise DischargeError when an error occurs during the discharge + process. + ''' + headers = DEFAULT_PROTOCOL_VERSION + payload = {'id': third_party_caveat_condition} + + response = requests.post(third_party_location + '/discharge', + headers=headers, + data=payload, + # timeout=TIME_OUT, TODO: add a time out + cookies=self._jar) + status_code = response.status_code + if status_code == 200: + return _extract_macaroon_from_response(response) + if (status_code == 401 and + response.headers.get('WWW-Authenticate') == 'Macaroon'): + error = response.json() + if error.get('Code', '') != ERR_INTERACTION_REQUIRED: + return DischargeException('unable to get code from discharge') + info = _extract_urls(response) + self._visit_page(info.visit_url) + # Wait on the wait url and then get a macaroon if validated. + return _acquire_macaroon_from_wait(info.wait_url) + + +def _decode_caveat(key, caveat): + '''Attempts to decode caveat by decrypting the encrypted part using key. + + @param key a nacl key. + @param caveat bytes to be decoded. + @return a dict of third party caveat info. + ''' + data = base64.b64decode(caveat).decode('utf-8') + tpid = json.loads(data) + third_party_public_key = nacl.public.PublicKey( + base64.b64decode(tpid['ThirdPartyPublicKey'])) + if key.public_key != third_party_public_key: + return 'some error' + if tpid.get('FirstPartyPublicKey', None) is None: + return 'target service public key not specified' + # The encrypted string is base64 encoded in the JSON representation. + secret = base64.b64decode(tpid['Id']) + first_party_public_key = nacl.public.PublicKey( + base64.b64decode(tpid['FirstPartyPublicKey'])) + box = Box(key, + first_party_public_key) + c = box.decrypt(secret, base64.b64decode(tpid['Nonce'])) + record = json.loads(c.decode('utf-8')) + return { + 'Condition': record['Condition'], + 'FirstPartyPublicKey': first_party_public_key, + 'ThirdPartyKeyPair': key, + 'RootKey': base64.b64decode(record['RootKey']), + 'Caveat': caveat, + 'MacaroonId': id, + } + + +def _extract_macaroon_from_response(response): + '''Extract the macaroon from a direct successful discharge. + + @param response from direct successful discharge. + @return a macaroon object. + @raises DischargeError if any error happens. + ''' + response_json = response.json() + return utils.deserialize(response_json['Macaroon']) + + +def _acquire_macaroon_from_wait(wait_url): + ''' Return the macaroon acquired from the wait endpoint. + + Note that will block until the user interaction has completed. + + @param wait_url the get url to call to get a macaroon. + @return a macaroon object + @raises DischargeError if any error happens. + ''' + resp = requests.get(wait_url, headers=DEFAULT_PROTOCOL_VERSION) + response_json = resp.json() + macaroon = response_json['Macaroon'] + return utils.deserialize(macaroon) + + +def _extract_urls(response): + '''Return _Info of the visit and wait URL from response. + + @param response the response from the discharge endpoint. + @return a _Info object of the visit and wait URL. + @raises DischargeError for ant error during the process response. + ''' + response_json = response.json() + visit_url = response_json['Info']['VisitURL'] + wait_url = response_json['Info']['WaitURL'] + return _Info(visit_url=visit_url, wait_url=wait_url) + + +class ThirdPartyInfo: + def __init__(self, version, public_key): + ''' + @param version holds latest the bakery protocol version supported + by the discharger. + @param public_key holds the public nacl key of the third party. + ''' + self.version = version + self.public_key = public_key diff --git a/macaroonbakery/checkers.py b/macaroonbakery/checkers.py new file mode 100644 index 0000000..8d72eb9 --- /dev/null +++ b/macaroonbakery/checkers.py @@ -0,0 +1,23 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. + +import collections + +_Caveat = collections.namedtuple('Caveat', 'condition location namespace') + + +class Caveat(_Caveat): + '''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) diff --git a/macaroonbakery/codec.py b/macaroonbakery/codec.py new file mode 100644 index 0000000..4015bbb --- /dev/null +++ b/macaroonbakery/codec.py @@ -0,0 +1,299 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. + +import base64 +import json +import namespace + +from nacl.public import Box, PublicKey +from nacl.encoding import Base64Encoder +import six + +import bakery +import macaroon + +_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.BAKERY_V1: + return _encode_caveat_v1(condition, root_key, + third_party_info.public_key, key) + if (third_party_info.version == bakery.BAKERY_V2 or + third_party_info.version == bakery.BAKERY_V3): + 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 public key 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 nacl public key + @param key nacl private key + @return a base64 encoded bytes + ''' + plain_data = json.dumps({ + 'RootKey': base64.b64encode(root_key).decode('ascii'), + 'Condition': condition + }) + box = Box(key, third_party_pub_key) + + encrypted = box.encrypt(six.b(plain_data)) + nonce = encrypted[0:Box.NONCE_SIZE] + encrypted = encrypted[Box.NONCE_SIZE:] + return base64.b64encode(six.b(json.dumps({ + 'ThirdPartyPublicKey': third_party_pub_key.encode( + Base64Encoder).decode('ascii'), + 'FirstPartyPublicKey': key.public_key.encode( + Base64Encoder).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.BAKERY_V3: + ns_data = ns.serialize() + data = bytearray() + data.append(version) + data.extend(third_party_pub_key.encode()[:_PUBLIC_KEY_PREFIX_LEN]) + data.extend(key.public_key.encode()[:]) + secret = _encode_secret_part_v2_v3(version, condition, root_key, ns_data) + box = Box(key, third_party_pub_key) + encrypted = box.encrypt(secret) + nonce = encrypted[0:Box.NONCE_SIZE] + encrypted = encrypted[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.BAKERY_V3: + _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 ValueError('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.BAKERY_V2 or first_as_int == bakery.BAKERY_V3: + if (len(caveat) < _VERSION3_CAVEAT_MIN_LEN + and first_as_int == bakery.BAKERY_V3): + # If it has the version 3 caveat tag and it's too short, it's + # almost certainly an id, not an encrypted payload. + raise ValueError( + 'caveat id payload not provided for caveat id {}'.format( + caveat)) + return _decode_caveat_v2_v3(first_as_int, key, caveat) + raise NotImplementedError('only bakery v1 supported') + + +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 = PublicKey(base64.b64decode(wrapper['ThirdPartyPublicKey'])) + if key.public_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 = PublicKey(base64.b64decode( + wrapper.get('FirstPartyPublicKey'))) + + box = Box(key, fp_public_key) + c = box.decrypt(secret, nonce) + record = json.loads(c.decode('utf-8')) + fp_key = PublicKey(base64.b64decode(wrapper.get('FirstPartyPublicKey'))) + return macaroon.ThirdPartyCaveatInfo( + record.get('Condition'), + fp_key, + key, + base64.b64decode(record.get('RootKey')), + caveat, + bakery.BAKERY_V1, + macaroon.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 + Box.NONCE_SIZE + 16): + raise ValueError('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()[:_PUBLIC_KEY_PREFIX_LEN] != pk_prefix: + raise ValueError('public key mismatch') + + first_party_pub = caveat[:_KEY_LEN] + caveat = caveat[_KEY_LEN:] + nonce = caveat[:Box.NONCE_SIZE] + caveat = caveat[Box.NONCE_SIZE:] + fp_public_key = PublicKey(first_party_pub) + box = Box(key, fp_public_key) + data = box.decrypt(caveat, nonce) + root_key, condition, ns = _decode_secret_part_v2_v3(version, data) + return macaroon.ThirdPartyCaveatInfo( + condition.decode('utf-8'), + fp_public_key, + key, + root_key, + original_caveat, + version, + ns + ) + + +def _decode_secret_part_v2_v3(version, data): + if len(data) < 1: + raise ValueError('secret part too short') + got_version = six.byte2int(data[:1]) + data = data[1:] + if version != got_version: + raise ValueError( + '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.BAKERY_V3: + namespace_length, read = _decode_uvarint(data) + data = data[read:] + ns_data = data[:namespace_length] + data = data[namespace_length:] + ns = namespace.deserialize_namespace(ns_data) + else: + ns = macaroon.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/httpbakery/__init__.py b/macaroonbakery/httpbakery/__init__.py new file mode 100644 index 0000000..4ebcf23 --- /dev/null +++ b/macaroonbakery/httpbakery/__init__.py @@ -0,0 +1 @@ +from .client import BakeryAuth # NOQA diff --git a/macaroonbakery/httpbakery/agent.py b/macaroonbakery/httpbakery/agent.py new file mode 100644 index 0000000..3676bae --- /dev/null +++ b/macaroonbakery/httpbakery/agent.py @@ -0,0 +1,53 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. + +import base64 +import json + +import nacl.public +import nacl.encoding +import requests.cookies +import six +from six.moves.urllib.parse import urlparse + + +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) as e: + raise AgentFileFormatError("invalid agent file", e) diff --git a/macaroonbakery/httpbakery/client.py b/macaroonbakery/httpbakery/client.py new file mode 100644 index 0000000..32f35dd --- /dev/null +++ b/macaroonbakery/httpbakery/client.py @@ -0,0 +1,157 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. + +import base64 +import requests +from six.moves.http_cookiejar import Cookie +from six.moves.urllib.parse import urljoin +from six.moves.urllib.parse import urlparse + +from macaroonbakery.bakery import discharge_all +from macaroonbakery import utils + +ERR_INTERACTION_REQUIRED = 'interaction required' +ERR_DISCHARGE_REQUIRED = 'macaroon discharge required' +TIME_OUT = 30 +MAX_DISCHARGE_RETRIES = 3 + + +class BakeryAuth: + ''' BakeryAuth holds the context for making HTTP requests with macaroons. + + This will automatically acquire and discharge macaroons around the + requests framework. + Usage: + from macaroonbakery import httpbakery + jar = requests.cookies.RequestsCookieJar() + resp = requests.get('some protected url', + cookies=jar, + auth=httpbakery.BakeryAuth(cookies=jar)) + resp.raise_for_status() + ''' + def __init__(self, visit_page=None, key=None, + cookies=requests.cookies.RequestsCookieJar()): + ''' + + @param visit_page function called when the discharge process requires + further interaction taking a visit_url string as parameter. + @param key holds the client's private nacl key. If set, the client + will try to discharge third party caveats with the special location + "local" by using this key. + @param cookies storage for the cookies {CookieJar}. It should be the + same than in the requests cookies + ''' + if visit_page is None: + visit_page = utils.visit_page_with_browser + if 'agent-login' in cookies.keys(): + self._visit_page = _visit_page_for_agent(cookies, key) + else: + self._visit_page = visit_page + self._jar = cookies + self._key = key + + def __call__(self, req): + req.headers['Bakery-Protocol-Version'] = '1' + hook = _prepare_discharge_hook(req.copy(), self._key, self._jar, + self._visit_page) + req.register_hook(event='response', hook=hook) + return req + + +def _prepare_discharge_hook(req, key, jar, visit_page): + ''' 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_401 = (response.status_code == 401 + and response.headers.get('WWW-Authenticate') == + 'Macaroon') + if not status_401 and response.status_code != 407: + return response + if response.headers.get('Content-Type') != 'application/json': + return response + + try: + error = response.json() + except: + raise BakeryException( + 'unable to read discharge error response') + if error.get('Code') != ERR_DISCHARGE_REQUIRED: + return response + Retry.count += 1 + if Retry.count > MAX_DISCHARGE_RETRIES: + raise BakeryException('too many discharges') + info = error.get('Info') + if not isinstance(info, dict): + raise BakeryException( + 'unable to read info in discharge error response') + serialized_macaroon = info.get('Macaroon') + if not isinstance(serialized_macaroon, dict): + raise BakeryException( + 'unable to read macaroon in discharge error response') + + macaroon = utils.deserialize(serialized_macaroon) + discharges = discharge_all(macaroon, visit_page, jar, key) + encoded_discharges = map(utils.serialize_macaroon_string, discharges) + + macaroons = '[' + ','.join(encoded_discharges) + ']' + all_macaroons = base64.urlsafe_b64encode( + macaroons.encode('utf-8')).decode('ascii') + + full_path = urljoin(response.url, + info['MacaroonPath']) + parsed_url = urlparse(full_path) + if info and info.get('CookieNameSuffix'): + name = 'macaroon-' + info['CookieNameSuffix'] + else: + name = 'macaroon-' + discharges[0].signature + cookie = Cookie( + version=0, + name=name, + value=all_macaroons, + port=None, + port_specified=False, + domain=parsed_url[1], + domain_specified=True, + domain_initial_dot=False, + path=parsed_url[2], + path_specified=True, + secure=False, + expires=None, + discard=False, + comment=None, + comment_url=None, + rest=None, + rfc2109=False) + jar.set_cookie(cookie) + # 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 = jar + req.headers.pop('Cookie', None) + req.prepare_cookies(req._cookies) + req.headers['Bakery-Protocol-Version'] = '1' + with requests.Session() as s: + return s.send(req) + return hook + + +class BakeryException(requests.RequestException): + ''' Bakery exception ''' + + +def _visit_page_for_agent(cookies, key): + def visit_page_for_agent(visit_url): + resp = requests.get(visit_url, cookies=cookies, + auth=BakeryAuth(cookies=cookies, key=key)) + resp.raise_for_status() + return visit_page_for_agent diff --git a/macaroonbakery/json_serializer.py b/macaroonbakery/json_serializer.py new file mode 100644 index 0000000..2faea00 --- /dev/null +++ b/macaroonbakery/json_serializer.py @@ -0,0 +1,75 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. + +import base64 +import json + +from pymacaroons.macaroon import Macaroon +from pymacaroons.caveat import Caveat + + +class JsonSerializer(object): + '''Serializer used to produce JSON macaroon format v1. + ''' + def serialize(self, macaroon): + '''Serialize the macaroon in JSON format v1. + + @param macaroon the macaroon to serialize. + @return JSON macaroon. + ''' + serialized = { + 'identifier': macaroon.identifier, + 'signature': macaroon.signature + } + if macaroon.location: + serialized['location'] = macaroon.location + if macaroon.caveats: + serialized['caveats'] = [ + caveat_v1_to_dict(caveat) for caveat in macaroon.caveats + ] + return json.dumps(serialized) + + def deserialize(self, serialized): + '''Deserialize a JSON macaroon v1. + + @param serialized the macaroon in JSON format v1. + @return the macaroon object. + ''' + from macaroonbakery import utils + caveats = [] + deserialized = json.loads(serialized) + + for c in deserialized['caveats']: + caveat = Caveat( + caveat_id=c['cid'], + verification_key_id=( + utils.raw_urlsafe_b64decode(c['vid']) if c.get('vid') + else None + ), + location=( + c['cl'] if c.get('cl') else None + ) + ) + caveats.append(caveat) + + return Macaroon( + location=deserialized['location'], + identifier=deserialized['identifier'], + caveats=caveats, + signature=deserialized['signature'] + ) + + +def caveat_v1_to_dict(c): + ''' Return a caveat as a dictionary for export as the JSON + macaroon v1 format + ''' + serialized = {} + if len(c.caveat_id) > 0: + serialized['cid'] = c.caveat_id + if c.verification_key_id: + serialized['vid'] = base64.urlsafe_b64encode( + c.verification_key_id).decode('ascii') + if c.location: + serialized['cl'] = c.location + return serialized diff --git a/macaroonbakery/macaroon.py b/macaroonbakery/macaroon.py new file mode 100644 index 0000000..b0a89bb --- /dev/null +++ b/macaroonbakery/macaroon.py @@ -0,0 +1,311 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. + +import base64 +import copy +import logging +import os + +import bakery +import codec +import pymacaroons + +import namespace + +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 + 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): + '''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 ns + ''' + if version > bakery.LATEST_BAKERY_VERSION: + log.info('use last known version:{} instead of: {}'.format( + bakery.LATEST_BAKERY_VERSION, version + )) + version = bakery.LATEST_BAKERY_VERSION + # m holds the underlying macaroon. + self._macaroon = pymacaroons.Macaroon(location=location, key=root_key, + identifier=id) + # version holds the version of the macaroon. + self.version = macaroon_version(version) + self.caveat_data = {} + + def add_caveat(self, cav, key=None, loc=None): + '''Return a new macaroon with the given caveat added. + + 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 nacl 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 + if key is None: + raise ValueError( + 'no private key to encrypt third party caveat') + local_info, ok = parse_local_location(cav.location) + if ok: + 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' + 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 + + caveat_info = codec.encode_caveat(cav.condition, root_key, info, + key, None) + if info.version < bakery.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 + + m = self._macaroon.add_third_party_caveat(cav.location, root_key, id) + new_macaroon = copy.copy(self) + new_macaroon._macaroon = m + return new_macaroon + + def add_caveats(self, cavs, key, loc): + '''Return a new macaroon with all caveats added. + + 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 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 + for cav in cavs: + macaroon = macaroon.add_caveat(cav, key, loc) + return macaroon + + 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. + + @return a dictionary holding the macaroon data + in V1 JSON format + ''' + 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') + + 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 string + @return string + ''' + raise NotImplementedError + + 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 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.BAKERY_V0, bakery.BAKERY_V1]: + return MACAROON_V1 + return MACAROON_V2 + + +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 false if the location does not represent a local + caveat location. + @return a tuple of location and if the location is local. + ''' + if not(loc.startswith('local ')): + return (), False + v = bakery.BAKERY_V1 + fields = loc.split() + fields = fields[1:] # Skip 'local' + if len(fields) == 2: + try: + v = int(fields[0]) + except ValueError: + return (), False + 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 diff --git a/macaroonbakery/namespace.py b/macaroonbakery/namespace.py new file mode 100644 index 0000000..ae0fa91 --- /dev/null +++ b/macaroonbakery/namespace.py @@ -0,0 +1,115 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. + +import collections +import six + +# StdNamespace holds the URI of the standard checkers schema. +STD_NAMESPACE = 'std' + + +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().decode('utf-8') + + def __eq__(self, other): + return self._uri_to_prefix == other._uri_to_prefix + + def serialize(self): + '''Returns a serialize 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 six.b(' '.join(data)) + + 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 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: + k, v = kv.split(':') + uri_to_prefix[k] = v + return Namespace(uri_to_prefix) diff --git a/macaroonbakery/tests/__init__.py b/macaroonbakery/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/macaroonbakery/tests/test_agent.py b/macaroonbakery/tests/test_agent.py new file mode 100644 index 0000000..86133fe --- /dev/null +++ b/macaroonbakery/tests/test_agent.py @@ -0,0 +1,149 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. + +import base64 +import json +import os +import tempfile +from unittest import TestCase + +import nacl.encoding +import requests.cookies +import six + +import macaroonbakery.httpbakery.agent as agent + + +class TestAgents(TestCase): + def setUp(self): + fd, filename = tempfile.mkstemp() + with os.fdopen(fd, 'w') as f: + f.write(agent_file) + self.agent_filename = filename + fd, filename = tempfile.mkstemp() + with os.fdopen(fd, 'w') as f: + f.write(bad_key_agent_file) + self.bad_key_agent_filename = filename + fd, filename = tempfile.mkstemp() + with os.fdopen(fd, 'w') as f: + f.write(no_username_agent_file) + self.no_username_agent_filename = filename + + def tearDown(self): + os.remove(self.agent_filename) + 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): + with self.assertRaises(agent.AgentFileFormatError): + agent.load_agent_file(self.bad_key_agent_filename) + + def test_load_agents_with_no_username(self): + with self.assertRaises(agent.AgentFileFormatError): + agent.load_agent_file(self.no_username_agent_filename) + + +agent_file = """ +{ + "key": { + "public": "YAhRSsth3a36mRYqQGQaLiS4QJax0p356nd+B8x7UQE=", + "private": "CqoSgj06Zcgb4/S6RT4DpTjLAfKoznEY3JsShSjKJEU=" + }, + "agents": [{ + "url": "https://1.example.com/", + "username": "user-1" + }, { + "url": "https://2.example.com/discharger", + "username": "user-2" + }] +} +""" + + +bad_key_agent_file = """ +{ + "key": { + "public": "YAhRSsth3a36mRYqQGQaLiS4QJax0p356nd+B8x7UQE=", + "private": "CqoSgj06Zcgb4/S6RT4DpTjLAfKoznEY3JsShSjKJE==" + }, + "agents": [{ + "url": "https://1.example.com/", + "username": "user-1" + }, { + "url": "https://2.example.com/discharger", + "username": "user-2" + }] +} +""" + + +no_username_agent_file = """ +{ + "key": { + "public": "YAhRSsth3a36mRYqQGQaLiS4QJax0p356nd+B8x7UQE=", + "private": "CqoSgj06Zcgb4/S6RT4DpTjLAfKoznEY3JsShSjKJEU=" + }, + "agents": [{ + "url": "https://1.example.com/" + }, { + "url": "https://2.example.com/discharger", + "username": "user-2" + }] +} +""" diff --git a/macaroonbakery/tests/test_bakery.py b/macaroonbakery/tests/test_bakery.py new file mode 100644 index 0000000..724b264 --- /dev/null +++ b/macaroonbakery/tests/test_bakery.py @@ -0,0 +1,166 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +from unittest import TestCase + +import requests + +from mock import ( + patch, +) + +from httmock import ( + HTTMock, + urlmatch, + response +) + +from macaroonbakery import httpbakery + +ID_PATH = 'http://example.com/someprotecteurl' + +json_macaroon = { + u'identifier': u'macaroon-identifier', + u'caveats': [ + { + u'cl': u'http://example.com/identity/v1/discharger', + u'vid': u'zgtQa88oS9UF45DlJniRaAUT4qqHhLxQzCeUU9N2O1Uu-' + u'yhFulgGbSA0zDGdkrq8YNQAxGiARA_-AGxyoh25kiTycb8u47pD', + u'cid': u'eyJUaGlyZFBhcnR5UHV' + }, { + u'cid': u'allow read-no-terms write' + }, { + u'cid': u'time-before 2016-07-19T14:29:14.312669464Z' + }], + u'location': u'charmstore', + u'signature': u'52d17cb11f5c84d58441bc0ffd7cc396' + u'5115374ce2fa473ecf06265b5d4d9e81' +} + +discharge_token = [{ + u'identifier': u'token-identifier===', + u'caveats': [{ + u'cid': u'declared username someone' + }, { + u'cid': u'time-before 2016-08-15T15:55:52.428319076Z' + }, { + u'cid': u'origin ' + }], + u'location': u'https://example.com/identity', + u'signature': u'5ae0e7a2abf806bdd92f510fcd3' + u'198f520691259abe76ffae5623dae048769ef' +}] + +discharged_macaroon = { + u'identifier': u'discharged-identifier=', + u'caveats': [{ + u'cid': u'declared uuid a1130b10-3deb-59b7-baf0-c2a3f83e7382' + }, { + u'cid': u'declared username someone' + }, { + u'cid': u'time-before 2016-07-19T15:55:52.432439055Z' + }], + u'location': u'', + u'signature': u'3513db5503ab17f9576760cd28' + u'ce658ce8bf6b43038255969fc3c1cd8b172345' +} + + +@urlmatch(path='.*/someprotecteurl') +def first_407_then_200(url, request): + if request.headers.get('cookie', '').startswith('macaroon-'): + return { + 'status_code': 200, + 'content': { + 'Value': 'some value' + } + } + else: + resp = response(status_code=407, + content={ + 'Info': { + 'Macaroon': json_macaroon, + 'MacaroonPath': '/', + 'CookieNameSuffix': 'test' + }, + 'Message': 'verification failed: no macaroon ' + 'cookies in request', + 'Code': 'macaroon discharge required' + }, + headers={'Content-Type': 'application/json'}) + return request.hooks['response'][0](resp) + + +@urlmatch(path='.*/someprotecteurl') +def valid_200(url, request): + return { + 'status_code': 200, + 'content': { + 'Value': 'some value' + } + } + + +@urlmatch(path='.*/discharge') +def discharge_200(url, request): + return { + 'status_code': 200, + 'content': { + 'Macaroon': discharged_macaroon + } + } + + +@urlmatch(path='.*/discharge') +def discharge_401(url, request): + return { + 'status_code': 401, + 'content': { + 'Code': 'interaction required', + 'Info': { + 'VisitURL': 'http://example.com/visit', + 'WaitURL': 'http://example.com/wait' + } + }, + 'headers': { + 'WWW-Authenticate': 'Macaroon' + } + } + + +@urlmatch(path='.*/wait') +def wait_after_401(url, request): + if request.url != 'http://example.com/wait': + return {'status_code': 500} + + return { + 'status_code': 200, + 'content': { + 'DischargeToken': discharge_token, + 'Macaroon': discharged_macaroon + } + } + + +class TestBakery(TestCase): + def test_discharge(self): + jar = requests.cookies.RequestsCookieJar() + with HTTMock(first_407_then_200): + with HTTMock(discharge_200): + resp = requests.get(ID_PATH, + cookies=jar, + auth=httpbakery.BakeryAuth(cookies=jar)) + resp.raise_for_status() + assert 'macaroon-test' in jar.keys() + + @patch('webbrowser.open') + def test_407_then_401_on_discharge(self, mock_open): + jar = requests.cookies.RequestsCookieJar() + with HTTMock(first_407_then_200): + with HTTMock(discharge_401): + with HTTMock(wait_after_401): + resp = requests.get(ID_PATH, + auth=httpbakery.BakeryAuth( + cookies=jar)) + resp.raise_for_status() + mock_open.assert_called_once_with(u'http://example.com/visit', new=1) + assert 'macaroon-test' in jar.keys() diff --git a/macaroonbakery/tests/test_codec.py b/macaroonbakery/tests/test_codec.py new file mode 100644 index 0000000..de1631c --- /dev/null +++ b/macaroonbakery/tests/test_codec.py @@ -0,0 +1,178 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +from unittest import TestCase + +import base64 +import six + +import nacl.utils +from nacl.public import PrivateKey +from nacl.encoding import Base64Encoder + +from macaroonbakery import bakery, codec, macaroon, namespace, utils + + +class TestCodec(TestCase): + def setUp(self): + self.fp_key = nacl.public.PrivateKey.generate() + self.tp_key = nacl.public.PrivateKey.generate() + + def test_v1_round_trip(self): + tp_info = bakery.ThirdPartyInfo(bakery.BAKERY_V1, + self.tp_key.public_key) + cid = codec.encode_caveat('is-authenticated-user', + b'a random string', + tp_info, + self.fp_key, + None) + + res = codec.decode_caveat(self.tp_key, cid) + self.assertEquals(res, macaroon.ThirdPartyCaveatInfo( + first_party_public_key=self.fp_key.public_key, + root_key=b'a random string', + condition='is-authenticated-user', + caveat=cid, + third_party_key_pair=self.tp_key, + version=bakery.BAKERY_V1, + ns=macaroon.legacy_namespace() + )) + + def test_v2_round_trip(self): + tp_info = bakery.ThirdPartyInfo(bakery.BAKERY_V2, + self.tp_key.public_key) + cid = codec.encode_caveat('is-authenticated-user', + b'a random string', + tp_info, + self.fp_key, + None) + res = codec.decode_caveat(self.tp_key, cid) + self.assertEquals(res, macaroon.ThirdPartyCaveatInfo( + first_party_public_key=self.fp_key.public_key, + root_key=b'a random string', + condition='is-authenticated-user', + caveat=cid, + third_party_key_pair=self.tp_key, + version=bakery.BAKERY_V2, + ns=macaroon.legacy_namespace() + )) + + def test_v3_round_trip(self): + tp_info = bakery.ThirdPartyInfo(bakery.BAKERY_V3, + self.tp_key.public_key) + ns = namespace.Namespace() + ns.register('testns', 'x') + cid = codec.encode_caveat('is-authenticated-user', + b'a random string', + tp_info, + self.fp_key, + ns) + res = codec.decode_caveat(self.tp_key, cid) + self.assertEquals(res, macaroon.ThirdPartyCaveatInfo( + first_party_public_key=self.fp_key.public_key, + root_key=b'a random string', + condition='is-authenticated-user', + caveat=cid, + third_party_key_pair=self.tp_key, + version=bakery.BAKERY_V3, + ns=ns + )) + + def test_empty_caveat_id(self): + with self.assertRaises(ValueError) as context: + codec.decode_caveat(self.tp_key, b'') + self.assertTrue('empty third party caveat' in str(context.exception)) + + def test_decode_caveat_v1_from_go(self): + tp_key = PrivateKey(base64.b64decode( + 'TSpvLpQkRj+T3JXnsW2n43n5zP/0X4zn0RvDiWC3IJ0=')) + fp_key = PrivateKey(base64.b64decode( + 'KXpsoJ9ujZYi/O2Cca6kaWh65MSawzy79LWkrjOfzcs=')) + fp_key.encode(Base64Encoder) + # This caveat has been generated from the go code + # to check the compatibilty + encrypted_cav = six.b( + 'eyJUaGlyZFBhcnR5UHVibGljS2V5IjoiOFA3R1ZZc3BlWlN4c' + '3hFdmJsSVFFSTFqdTBTSWl0WlIrRFdhWE40cmxocz0iLCJGaX' + 'JzdFBhcnR5UHVibGljS2V5IjoiSDlqSFJqSUxidXppa1VKd2o' + '5VGtDWk9qeW5oVmtTdHVsaUFRT2d6Y0NoZz0iLCJOb25jZSI6' + 'Ii9lWTRTTWR6TGFxbDlsRFc3bHUyZTZuSzJnVG9veVl0IiwiS' + 'WQiOiJra0ZuOGJEaEt4RUxtUjd0NkJxTU0vdHhMMFVqaEZjR1' + 'BORldUUExGdjVla1dWUjA4Uk1sbGJhc3c4VGdFbkhzM0laeVo' + '0V2lEOHhRUWdjU3ljOHY4eUt4dEhxejVEczJOYmh1ZDJhUFdt' + 'UTVMcVlNWitmZ2FNaTAxdE9DIn0=') + cav = codec.decode_caveat(tp_key, encrypted_cav) + self.assertEquals(cav, macaroon.ThirdPartyCaveatInfo( + condition='caveat condition', + first_party_public_key=fp_key.public_key, + third_party_key_pair=tp_key, + root_key=b'random', + caveat=encrypted_cav, + version=bakery.BAKERY_V1, + ns=macaroon.legacy_namespace() + )) + + def test_decode_caveat_v2_from_go(self): + tp_key = PrivateKey(base64.b64decode( + 'TSpvLpQkRj+T3JXnsW2n43n5zP/0X4zn0RvDiWC3IJ0=')) + fp_key = PrivateKey(base64.b64decode( + 'KXpsoJ9ujZYi/O2Cca6kaWh65MSawzy79LWkrjOfzcs=')) + # This caveat has been generated from the go code + # to check the compatibilty + encrypted_cav = base64.urlsafe_b64decode( + utils.add_base64_padding(six.b( + 'AvD-xlUf2MdGMgtu7OKRQnCP1OQJk6PKeFWRK26WIBA6DNwKGIHq9xGcHS9IZ' + 'Lh0cL6D9qpeKI0mXmCPfnwRQDuVYC8y5gVWd-oCGZaj5TGtk3byp2Vnw6ojmt' + 'sULDhY59YA_J_Y0ATkERO5T9ajoRWBxU2OXBoX6bImXA'))) + cav = codec.decode_caveat(tp_key, encrypted_cav) + self.assertEquals(cav, macaroon.ThirdPartyCaveatInfo( + condition='third party condition', + first_party_public_key=fp_key.public_key, + third_party_key_pair=tp_key, + root_key=b'random', + caveat=encrypted_cav, + version=bakery.BAKERY_V2, + ns=macaroon.legacy_namespace() + )) + + def test_decode_caveat_v3_from_go(self): + tp_key = PrivateKey(base64.b64decode( + 'TSpvLpQkRj+T3JXnsW2n43n5zP/0X4zn0RvDiWC3IJ0=')) + fp_key = PrivateKey(base64.b64decode( + 'KXpsoJ9ujZYi/O2Cca6kaWh65MSawzy79LWkrjOfzcs=')) + # This caveat has been generated from the go code + # to check the compatibilty + encrypted_cav = base64.urlsafe_b64decode( + utils.add_base64_padding(six.b( + 'A_D-xlUf2MdGMgtu7OKRQnCP1OQJk6PKeFWRK26WIBA6DNwKGNLeFSkD2M-8A' + 'EYvmgVH95GWu7T7caKxKhhOQFcEKgnXKJvYXxz1zin4cZc4Q6C7gVqA-J4_j3' + '1LX4VKxymqG62UGPo78wOv0_fKjr3OI6PPJOYOQgBMclemlRF2'))) + cav = codec.decode_caveat(tp_key, encrypted_cav) + self.assertEquals(cav, macaroon.ThirdPartyCaveatInfo( + condition='third party condition', + first_party_public_key=fp_key.public_key, + third_party_key_pair=tp_key, + root_key=b'random', + caveat=encrypted_cav, + version=bakery.BAKERY_V3, + ns=macaroon.legacy_namespace() + )) + + def test_encode_decode_varint(self): + tests = [ + (12, [12]), + (127, [127]), + (128, [128, 1]), + (129, [129, 1]), + (1234567, [135, 173, 75]), + (12131231231312, [208, 218, 233, 173, 136, 225, 2]) + ] + for test in tests: + data = bytearray() + expected = bytearray() + codec._encode_uvarint(test[0], data) + for v in test[1]: + expected.append(v) + self.assertEquals(data, expected) + val = codec._decode_uvarint(bytes(data)) + self.assertEquals(test[0], val[0]) + self.assertEquals(len(test[1]), val[1]) diff --git a/macaroonbakery/tests/test_macaroon.py b/macaroonbakery/tests/test_macaroon.py new file mode 100644 index 0000000..afc7d52 --- /dev/null +++ b/macaroonbakery/tests/test_macaroon.py @@ -0,0 +1,64 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. + +from unittest import TestCase + +import six + +import nacl.utils + +from macaroonbakery import bakery, macaroon, checkers, codec + + +class TestMacaroon(TestCase): + def test_new_macaroon(self): + m = macaroon.Macaroon(b'rootkey', + b'some id', + 'here', + bakery.LATEST_BAKERY_VERSION) + self.assertIsNotNone(m) + self.assertEquals(m._macaroon.identifier, 'some id') + self.assertEquals(m._macaroon.location, 'here') + self.assertEquals(m.version, macaroon.macaroon_version( + bakery.LATEST_BAKERY_VERSION)) + + def test_add_first_party_caveat(self): + m = macaroon.Macaroon('rootkey', + 'some id', + 'here', + bakery.LATEST_BAKERY_VERSION) + m = m.add_caveat(checkers.Caveat('test_condition')) + caveats = m.first_party_caveats() + self.assertEquals(len(caveats), 1) + self.assertEquals(caveats[0].caveat_id, 'test_condition') + + def test_add_third_party_caveat(self): + m = macaroon.Macaroon('rootkey', + 'some id', + 'here', + bakery.LATEST_BAKERY_VERSION) + loc = macaroon.ThirdPartyLocator() + fp_key = nacl.public.PrivateKey.generate() + tp_key = nacl.public.PrivateKey.generate() + + loc.add_info('test_location', + bakery.ThirdPartyInfo( + bakery.BAKERY_V1, + tp_key.public_key)) + m = m.add_caveat(checkers.Caveat(condition='test_condition', + location='test_location'), + fp_key, loc) + + tp_cav = m.third_party_caveats() + self.assertEquals(len(tp_cav), 1) + self.assertEquals(tp_cav[0].location, 'test_location') + cav = codec.decode_caveat(tp_key, six.b(tp_cav[0].caveat_id)) + self.assertEquals(cav, macaroon.ThirdPartyCaveatInfo( + condition='test_condition', + first_party_public_key=fp_key.public_key, + third_party_key_pair=tp_key, + root_key='random', + caveat=six.b(tp_cav[0].caveat_id), + version=bakery.BAKERY_V1, + ns=macaroon.legacy_namespace() + )) diff --git a/macaroonbakery/tests/test_namespace.py b/macaroonbakery/tests/test_namespace.py new file mode 100644 index 0000000..24eda29 --- /dev/null +++ b/macaroonbakery/tests/test_namespace.py @@ -0,0 +1,58 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. + +from unittest import TestCase + +from macaroonbakery import namespace + + +class TestNamespace(TestCase): + def test_serialize(self): + tests = [ + ('empty namespace', None, b''), + ('standard namespace', {'std': ''}, b'std:'), + ('several elements', { + 'std': '', + 'http://blah.blah': 'blah', + 'one': 'two', + 'foo.com/x.v0.1': 'z', + }, b'foo.com/x.v0.1:z http://blah.blah:blah one:two std:'), + ('sort by URI not by field', { + 'a': 'one', + 'a1': 'two', + }, b'a:one a1:two') + ] + for test in tests: + ns = namespace.Namespace(test[1]) + data = ns.serialize() + self.assertEquals(data, test[2]) + self.assertEquals(str(ns), test[2].decode('utf-8')) + + # Check that it can be deserialize to the same thing: + ns1 = namespace.deserialize_namespace(data) + self.assertEquals(ns1, ns) + + def test_register(self): + ns = namespace.Namespace(None) + ns.register('testns', 't') + prefix = ns.resolve('testns') + self.assertEquals(prefix, 't') + + ns.register('other', 'o') + prefix = ns.resolve('other') + self.assertEquals(prefix, 'o') + + # If we re-register the same URL, it does nothing. + ns.register('other', 'p') + prefix = ns.resolve('other') + self.assertEquals(prefix, 'o') + + def test_register_bad_uri(self): + ns = namespace.Namespace(None) + with self.assertRaises(KeyError): + ns.register('', 'x') + + def test_register_bad_prefix(self): + ns = namespace.Namespace(None) + with self.assertRaises(ValueError): + ns.register('std', 'x:1') diff --git a/macaroonbakery/utils.py b/macaroonbakery/utils.py new file mode 100644 index 0000000..c747ad3 --- /dev/null +++ b/macaroonbakery/utils.py @@ -0,0 +1,79 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. + +import base64 +import json +import webbrowser + +from pymacaroons import Macaroon + +from macaroonbakery import json_serializer + + +def deserialize(json_macaroon): + '''Deserialize a JSON macaroon into a macaroon object from pymacaroons. + + @param the JSON macaroon to deserialize as a dict. + @return the deserialized macaroon object. + ''' + return Macaroon.deserialize(json.dumps(json_macaroon), + json_serializer.JsonSerializer()) + + +def serialize_macaroon_string(macaroon): + '''Serialize macaroon object to 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 raw_urlsafe_b64decode(s): + '''Base64 decode with added padding and convertion to bytes. + + @param s string decode + @return bytes decoded + ''' + return base64.urlsafe_b64decode(add_base64_padding( + s.encode('ascii'))) + + +def raw_urlsafe_b64encode(b): + '''Base64 encode with padding removed. + + @param s string decode + @return bytes decoded + ''' + return remove_base64_padding(base64.urlsafe_b64encode(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) -- cgit v1.2.3