summaryrefslogtreecommitdiff
path: root/macaroonbakery
diff options
context:
space:
mode:
Diffstat (limited to 'macaroonbakery')
-rw-r--r--macaroonbakery/__init__.py17
-rw-r--r--macaroonbakery/bakery.py237
-rw-r--r--macaroonbakery/checkers.py23
-rw-r--r--macaroonbakery/codec.py299
-rw-r--r--macaroonbakery/httpbakery/__init__.py1
-rw-r--r--macaroonbakery/httpbakery/agent.py53
-rw-r--r--macaroonbakery/httpbakery/client.py157
-rw-r--r--macaroonbakery/json_serializer.py75
-rw-r--r--macaroonbakery/macaroon.py311
-rw-r--r--macaroonbakery/namespace.py115
-rw-r--r--macaroonbakery/tests/__init__.py0
-rw-r--r--macaroonbakery/tests/test_agent.py149
-rw-r--r--macaroonbakery/tests/test_bakery.py166
-rw-r--r--macaroonbakery/tests/test_codec.py178
-rw-r--r--macaroonbakery/tests/test_macaroon.py64
-rw-r--r--macaroonbakery/tests/test_namespace.py58
-rw-r--r--macaroonbakery/utils.py79
17 files changed, 1982 insertions, 0 deletions
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 <version> <pubkey>
+
+ 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
+ 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
--- /dev/null
+++ b/macaroonbakery/tests/__init__.py
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)