summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorColin Watson <cjwatson@debian.org>2017-11-03 12:13:13 +0000
committerColin Watson <cjwatson@debian.org>2017-11-03 16:06:25 +0000
commitc5a506f9683bcc1d122b8d3b2999e82799a64faf (patch)
tree138fec5e41a3148e33717e13829d9b5341649f16
parentd8e82d564b638965b8e9c9d6fb23ae9496fdf355 (diff)
parent3d9eaeb5dacee168a93da090e2c0d46eedbe51a2 (diff)
New upstream release (0.0.4)
-rw-r--r--Makefile2
-rw-r--r--debian/.git-dpm14
-rw-r--r--debian/changelog6
-rw-r--r--debian/control11
-rw-r--r--debian/patches/avoid-relative-imports.patch83
-rw-r--r--debian/patches/series1
-rwxr-xr-xdocs/conf.py2
-rw-r--r--macaroonbakery/__init__.py95
-rw-r--r--macaroonbakery/authorizer.py107
-rw-r--r--macaroonbakery/bakery.py148
-rw-r--r--macaroonbakery/checker.py409
-rw-r--r--macaroonbakery/checkers.py23
-rw-r--r--macaroonbakery/checkers/__init__.py50
-rw-r--r--macaroonbakery/checkers/auth_context.py58
-rw-r--r--macaroonbakery/checkers/caveat.py125
-rw-r--r--macaroonbakery/checkers/checkers.py243
-rw-r--r--macaroonbakery/checkers/conditions.py17
-rw-r--r--macaroonbakery/checkers/declared.py82
-rw-r--r--macaroonbakery/checkers/namespace.py (renamed from macaroonbakery/namespace.py)72
-rw-r--r--macaroonbakery/checkers/operation.py17
-rw-r--r--macaroonbakery/checkers/time.py18
-rw-r--r--macaroonbakery/checkers/utils.py13
-rw-r--r--macaroonbakery/codec.py153
-rw-r--r--macaroonbakery/discharge.py210
-rw-r--r--macaroonbakery/error.py77
-rw-r--r--macaroonbakery/httpbakery/__init__.py18
-rw-r--r--macaroonbakery/httpbakery/agent.py11
-rw-r--r--macaroonbakery/httpbakery/client.py26
-rw-r--r--macaroonbakery/httpbakery/error.py67
-rw-r--r--macaroonbakery/httpbakery/keyring.py56
-rw-r--r--macaroonbakery/identity.py126
-rw-r--r--macaroonbakery/internal/__init__.py0
-rw-r--r--macaroonbakery/internal/id.proto14
-rw-r--r--macaroonbakery/internal/id_pb2.py132
-rw-r--r--macaroonbakery/json_serializer.py75
-rw-r--r--macaroonbakery/keys.py92
-rw-r--r--macaroonbakery/macaroon.py440
-rw-r--r--macaroonbakery/oven.py254
-rw-r--r--macaroonbakery/store.py77
-rw-r--r--macaroonbakery/tests/__init__.py2
-rw-r--r--macaroonbakery/tests/common.py120
-rw-r--r--macaroonbakery/tests/test_agent.py13
-rw-r--r--macaroonbakery/tests/test_authorizer.py132
-rw-r--r--macaroonbakery/tests/test_checker.py963
-rw-r--r--macaroonbakery/tests/test_checkers.py356
-rw-r--r--macaroonbakery/tests/test_codec.py164
-rw-r--r--macaroonbakery/tests/test_discharge.py445
-rw-r--r--macaroonbakery/tests/test_discharge_all.py170
-rw-r--r--macaroonbakery/tests/test_keyring.py111
-rw-r--r--macaroonbakery/tests/test_macaroon.py230
-rw-r--r--macaroonbakery/tests/test_namespace.py15
-rw-r--r--macaroonbakery/tests/test_oven.py125
-rw-r--r--macaroonbakery/tests/test_store.py21
-rw-r--r--macaroonbakery/third_party.py53
-rw-r--r--macaroonbakery/utils.py16
-rw-r--r--macaroonbakery/versions.py9
-rw-r--r--requirements.txt11
-rwxr-xr-xsetup.py22
-rw-r--r--tox.ini4
59 files changed, 5702 insertions, 704 deletions
diff --git a/Makefile b/Makefile
index 4e87e5c..75d0e27 100644
--- a/Makefile
+++ b/Makefile
@@ -76,7 +76,7 @@ help:
.PHONY: lint
lint: setup
- @$(DEVENV)/bin/flake8 --ignore E731 --show-source macaroonbakery
+ @$(DEVENV)/bin/flake8 --show-source macaroonbakery --exclude macaroonbakery/internal/id_pb2.py
.PHONY: release
release: check
diff --git a/debian/.git-dpm b/debian/.git-dpm
index f0f64ed..0e8f4a8 100644
--- a/debian/.git-dpm
+++ b/debian/.git-dpm
@@ -1,11 +1,11 @@
# see git-dpm(1) from git-dpm package
-1c453126cefd68073c089b7b334b8c793b38c152
-1c453126cefd68073c089b7b334b8c793b38c152
-79ff2842fa477ee0693ea167c0a74cd7cf080d27
-79ff2842fa477ee0693ea167c0a74cd7cf080d27
-py-macaroon-bakery_0.0.3.orig.tar.gz
-5a749b8bfce5cdc00084ced3301a356681c08647
-29083
+3d9eaeb5dacee168a93da090e2c0d46eedbe51a2
+3d9eaeb5dacee168a93da090e2c0d46eedbe51a2
+3d9eaeb5dacee168a93da090e2c0d46eedbe51a2
+3d9eaeb5dacee168a93da090e2c0d46eedbe51a2
+py-macaroon-bakery_0.0.4.orig.tar.gz
+0991cc6e4167b4b83740f03baa89123ff6d9a424
+69675
debianTag="debian/%e%v"
patchedTag="patched/%e%v"
upstreamTag="upstream/%e%u"
diff --git a/debian/changelog b/debian/changelog
index 3c79341..92170ca 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,3 +1,9 @@
+py-macaroon-bakery (0.0.4-1) UNRELEASED; urgency=medium
+
+ * New upstream release.
+
+ -- Colin Watson <cjwatson@debian.org> Fri, 03 Nov 2017 12:13:49 +0000
+
py-macaroon-bakery (0.0.3-1) unstable; urgency=medium
* Initial release (closes: #866779).
diff --git a/debian/control b/debian/control
index a641fd2..d592607 100644
--- a/debian/control
+++ b/debian/control
@@ -8,12 +8,15 @@ Build-Depends: debhelper (>= 9~),
python3-all,
python3-httmock,
python3-mock,
- python3-nacl (>= 1.1.1),
+ python3-nacl (>= 1.1.2),
python3-nose,
- python3-pymacaroons (>= 0.10.0),
- python3-requests (>= 2.16.5),
+ python3-protobuf (>= 3.0.0),
+ python3-pymacaroons (>= 0.12.0),
+ python3-requests (>= 2.18.1),
+ python3-rfc3339 (>= 1.0),
python3-setuptools,
- python3-six (>= 1.10.0)
+ python3-six (>= 1.11.0),
+ python3-tz (>= 2017.2)
Standards-Version: 4.1.1
X-Python3-Version: >= 3.5
Vcs-Git: https://anonscm.debian.org/git/python-modules/packages/py-macaroon-bakery.git
diff --git a/debian/patches/avoid-relative-imports.patch b/debian/patches/avoid-relative-imports.patch
deleted file mode 100644
index 4b02a1e..0000000
--- a/debian/patches/avoid-relative-imports.patch
+++ /dev/null
@@ -1,83 +0,0 @@
-From 1c453126cefd68073c089b7b334b8c793b38c152 Mon Sep 17 00:00:00 2001
-From: Colin Watson <cjwatson@debian.org>
-Date: Thu, 2 Nov 2017 14:16:23 +0000
-Subject: Avoid relative imports to fix Python 3 tests
-
-This is fixed in a different way in 0.0.4.
-
-Forwarded: not-needed
-Last-Update: 2017-11-02
-
-Patch-Name: avoid-relative-imports.patch
----
- macaroonbakery/codec.py | 11 ++++++++---
- macaroonbakery/macaroon.py | 6 +++---
- 2 files changed, 11 insertions(+), 6 deletions(-)
-
-diff --git a/macaroonbakery/codec.py b/macaroonbakery/codec.py
-index 4015bbb..f5cc493 100644
---- a/macaroonbakery/codec.py
-+++ b/macaroonbakery/codec.py
-@@ -3,14 +3,13 @@
-
- import base64
- import json
--import namespace
-+from macaroonbakery import namespace
-
- from nacl.public import Box, PublicKey
- from nacl.encoding import Base64Encoder
- import six
-
--import bakery
--import macaroon
-+from macaroonbakery import bakery
-
- _PUBLIC_KEY_PREFIX_LEN = 4
- _KEY_LEN = 32
-@@ -178,6 +177,8 @@ def _decode_caveat_v1(key, caveat):
- @param caveat a base64 encoded JSON string.
- '''
-
-+ from macaroonbakery import macaroon
-+
- data = base64.b64decode(caveat).decode('utf-8')
- wrapper = json.loads(data)
- tp_public_key = PublicKey(base64.b64decode(wrapper['ThirdPartyPublicKey']))
-@@ -212,6 +213,8 @@ def _decode_caveat_v1(key, caveat):
- def _decode_caveat_v2_v3(version, key, caveat):
- '''Decodes a version 2 or version 3 caveat.
- '''
-+ from macaroonbakery import macaroon
-+
- if (len(caveat) < 1 + _PUBLIC_KEY_PREFIX_LEN +
- _KEY_LEN + Box.NONCE_SIZE + 16):
- raise ValueError('caveat id too short')
-@@ -243,6 +246,8 @@ def _decode_caveat_v2_v3(version, key, caveat):
-
-
- def _decode_secret_part_v2_v3(version, data):
-+ from macaroonbakery import macaroon
-+
- if len(data) < 1:
- raise ValueError('secret part too short')
- got_version = six.byte2int(data[:1])
-diff --git a/macaroonbakery/macaroon.py b/macaroonbakery/macaroon.py
-index b0a89bb..954161c 100644
---- a/macaroonbakery/macaroon.py
-+++ b/macaroonbakery/macaroon.py
-@@ -6,11 +6,11 @@ import copy
- import logging
- import os
-
--import bakery
--import codec
-+from macaroonbakery import bakery
-+from macaroonbakery import codec
- import pymacaroons
-
--import namespace
-+from macaroonbakery import namespace
-
- MACAROON_V1, MACAROON_V2 = 1, 2
-
diff --git a/debian/patches/series b/debian/patches/series
deleted file mode 100644
index 5b8cfe5..0000000
--- a/debian/patches/series
+++ /dev/null
@@ -1 +0,0 @@
-avoid-relative-imports.patch
diff --git a/docs/conf.py b/docs/conf.py
index 787735f..75593c5 100755
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -63,7 +63,7 @@ copyright = u'2017, Juju UI Team'
# the built documents.
#
# The short X.Y version and the full version.
-version = release = macaroonbakery.get_version()
+version = release = '0.0.4'
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
diff --git a/macaroonbakery/__init__.py b/macaroonbakery/__init__.py
index 8020901..dd2e6df 100644
--- a/macaroonbakery/__init__.py
+++ b/macaroonbakery/__init__.py
@@ -9,9 +9,96 @@ except ImportError:
else:
urllib3.contrib.pyopenssl.inject_into_urllib3()
-VERSION = (0, 0, 3)
+from macaroonbakery.versions import (
+ LATEST_BAKERY_VERSION, BAKERY_V3, BAKERY_V2, BAKERY_V1, BAKERY_V0
+)
+from macaroonbakery.authorizer import (
+ ClosedAuthorizer, EVERYONE, AuthorizerFunc, Authorizer, ACLAuthorizer
+)
+from macaroonbakery.codec import (
+ encode_caveat, decode_caveat, encode_uvarint
+)
+from macaroonbakery.checker import (
+ Op, LOGIN_OP, AuthInfo, AuthChecker, Checker
+)
+from macaroonbakery.error import (
+ ThirdPartyCaveatCheckFailed, CaveatNotRecognizedError, AuthInitError,
+ PermissionDenied, IdentityError, DischargeRequiredError, VerificationError,
+ ThirdPartyInfoNotFound
+)
+from macaroonbakery.identity import (
+ Identity, ACLIdentity, SimpleIdentity, IdentityClient, NoIdentities
+)
+from macaroonbakery.keys import generate_key, PrivateKey, PublicKey
+from macaroonbakery.store import MemoryOpsStore, MemoryKeyStore
+from macaroonbakery.third_party import (
+ ThirdPartyCaveatInfo, ThirdPartyInfo, legacy_namespace
+)
+from macaroonbakery.macaroon import (
+ Macaroon, MacaroonJSONDecoder, MacaroonJSONEncoder, ThirdPartyStore,
+ ThirdPartyLocator, macaroon_version
+)
+from macaroonbakery.discharge import (
+ discharge_all, discharge, local_third_party_caveat, ThirdPartyCaveatChecker
+)
+from macaroonbakery.oven import Oven, canonical_ops
+from macaroonbakery.bakery import Bakery
-def get_version():
- '''Return the macaroon bakery version as a string.'''
- return '.'.join(map(str, VERSION))
+__all__ = [
+ 'ACLIdentity',
+ 'ACLAuthorizer',
+ 'AuthChecker',
+ 'AuthInfo',
+ 'AuthInitError',
+ 'Authorizer',
+ 'AuthorizerFunc',
+ 'Bakery',
+ 'BAKERY_V0',
+ 'BAKERY_V1',
+ 'BAKERY_V2',
+ 'BAKERY_V3',
+ 'Bakery',
+ 'CaveatNotRecognizedError',
+ 'Checker',
+ 'ClosedAuthorizer',
+ 'DischargeRequiredError',
+ 'EVERYONE',
+ 'Identity',
+ 'IdentityClient',
+ 'IdentityError',
+ 'LATEST_BAKERY_VERSION',
+ 'LOGIN_OP',
+ 'Macaroon',
+ 'MacaroonJSONDecoder',
+ 'MacaroonJSONEncoder',
+ 'MemoryKeyStore',
+ 'MemoryOpsStore',
+ 'NoIdentities',
+ 'Op',
+ 'Oven',
+ 'PermissionDenied',
+ 'PrivateKey',
+ 'PublicKey',
+ 'NoIdentities',
+ 'SimpleIdentity',
+ 'ThirdPartyCaveatCheckFailed',
+ 'ThirdPartyCaveatChecker',
+ 'ThirdPartyCaveatInfo',
+ 'ThirdPartyInfo',
+ 'ThirdPartyInfoNotFound',
+ 'ThirdPartyLocator',
+ 'ThirdPartyStore',
+ 'VERSION',
+ 'VerificationError',
+ 'canonical_ops',
+ 'decode_caveat',
+ 'discharge',
+ 'discharge_all',
+ 'encode_caveat',
+ 'encode_uvarint',
+ 'generate_key',
+ 'legacy_namespace',
+ 'local_third_party_caveat',
+ 'macaroon_version',
+]
diff --git a/macaroonbakery/authorizer.py b/macaroonbakery/authorizer.py
new file mode 100644
index 0000000..b7128c0
--- /dev/null
+++ b/macaroonbakery/authorizer.py
@@ -0,0 +1,107 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+import abc
+
+import macaroonbakery
+
+
+# EVERYONE is recognized by ACLAuthorizer as the name of a
+# group that has everyone in it.
+EVERYONE = 'everyone'
+
+
+class Authorizer(object):
+ ''' Used to check whether a given user is allowed to perform a set of
+ operations.
+ '''
+ __metaclass__ = abc.ABCMeta
+
+ @abc.abstractmethod
+ def authorize(self, ctx, id, ops):
+ ''' Checks whether the given identity (which will be None when there is
+ no authenticated user) is allowed to perform the given operations.
+ It should raise an exception only when the authorization cannot be
+ determined, not when the user has been denied access.
+
+ On success, each element of allowed holds whether the respective
+ element of ops has been allowed, and caveats holds any additional
+ third party caveats that apply.
+ If allowed is shorter then ops, the additional elements are assumed to
+ be False.
+ ctx(AuthContext) is the context of the authorization request.
+ :return: a list of boolean and a list of caveats
+ '''
+ raise NotImplementedError('authorize method must be defined in '
+ 'subclass')
+
+
+class AuthorizerFunc(Authorizer):
+ ''' Implements a simplified version of Authorizer that operates on a single
+ operation at a time.
+ '''
+ def __init__(self, f):
+ '''
+ :param f: a function that takes an identity that operates on a single
+ operation at a time. Will return if this op is allowed as a boolean and
+ and a list of caveat that holds any additional third party caveats
+ that apply.
+ '''
+ self._f = f
+
+ def authorize(self, ctx, identity, ops):
+ '''Implements Authorizer.authorize by calling f with the given identity
+ for each operation.
+ '''
+ allowed = []
+ caveats = []
+ for op in ops:
+ ok, fcaveats = self._f(ctx, identity, op)
+ allowed.append(ok)
+ if fcaveats is not None:
+ caveats.extend(fcaveats)
+ return allowed, caveats
+
+
+class ACLAuthorizer(Authorizer):
+ ''' ACLAuthorizer is an Authorizer implementation that will check access
+ control list (ACL) membership of users. It uses get_acl to find out
+ the ACLs that apply to the requested operations and will authorize an
+ operation if an ACL contains the group "everyone" or if the identity is
+ an instance of ACLIdentity and its allow method returns True for the ACL.
+ '''
+ def __init__(self, get_acl, allow_public=False):
+ '''
+ :param get_acl get_acl will be called with an auth context and an Op.
+ It should return the ACL that applies (an array of string ids).
+ If an entity cannot be found or the action is not recognised,
+ get_acl should return an empty list but no error.
+ :param allow_public: boolean, If True and an ACL contains "everyone",
+ then authorization will be granted even if there is no logged in user.
+ '''
+ self._allow_public = allow_public
+ self._get_acl = get_acl
+
+ def authorize(self, ctx, identity, ops):
+ '''Implements Authorizer.authorize by calling identity.allow to
+ determine whether the identity is a member of the ACLs associated with
+ the given operations.
+ '''
+ if len(ops) == 0:
+ # Anyone is allowed to do nothing.
+ return [], []
+ allowed = [False] * len(ops)
+ has_allow = isinstance(identity, macaroonbakery.ACLIdentity)
+ for i, op in enumerate(ops):
+ acl = self._get_acl(ctx, op)
+ if has_allow:
+ allowed[i] = identity.allow(ctx, acl)
+ else:
+ allowed[i] = self._allow_public and EVERYONE in acl
+ return allowed, []
+
+
+class ClosedAuthorizer(Authorizer):
+ ''' An Authorizer implementation that will never authorize anything.
+ '''
+ def authorize(self, ctx, id, ops):
+ return [False] * len(ops), []
diff --git a/macaroonbakery/bakery.py b/macaroonbakery/bakery.py
index a3fcf88..1e03191 100644
--- a/macaroonbakery/bakery.py
+++ b/macaroonbakery/bakery.py
@@ -1,16 +1,14 @@
# 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 macaroonbakery import utils
+from macaroonbakery.discharge import discharge
+from macaroonbakery.checkers import checkers
+from macaroonbakery.oven import Oven
+from macaroonbakery.checker import Checker
-from pymacaroons import Macaroon
ERR_INTERACTION_REQUIRED = 'interaction required'
ERR_DISCHARGE_REQUIRED = 'macaroon discharge required'
@@ -18,11 +16,6 @@ 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
@@ -65,32 +58,6 @@ def discharge_all(macaroon, visit_page=None, jar=None, key=None):
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
@@ -111,7 +78,7 @@ class _Client:
caveats = macaroon.third_party_caveats()
for caveat in caveats:
location = caveat.location
- b_cav_id = caveat.caveat_id.encode('utf-8')
+ b_cav_id = caveat.caveat_id
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)
@@ -154,39 +121,6 @@ class _Client:
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.
@@ -226,12 +160,66 @@ def _extract_urls(response):
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.
+class Bakery(object):
+ '''Convenience class that contains both an Oven and a Checker.
+ '''
+ def __init__(self, location=None, locator=None, ops_store=None, key=None,
+ identity_client=None, checker=None, root_key_store=None,
+ authorizer=None):
+ '''Returns a new Bakery instance which combines an Oven with a
+ Checker for the convenience of callers that wish to use both
+ together.
+ :param: checker holds the checker used to check first party caveats.
+ If this is None, it will use checkers.Checker(None).
+ :param: root_key_store holds the root key store to use.
+ If you need to use a different root key store for different operations,
+ you'll need to pass a root_key_store_for_ops value to Oven directly.
+ :param: root_key_store If this is None, it will use MemoryKeyStore().
+ Note that that is almost certain insufficient for production services
+ that are spread across multiple instances or that need
+ to persist keys across restarts.
+ :param: locator is used to find out information on third parties when
+ adding third party caveats. If this is None, no non-local third
+ party caveats can be added.
+ :param: key holds the private key of the oven. If this is None,
+ no third party caveats may be added.
+ :param: identity_client holds the identity implementation to use for
+ authentication. If this is None, no authentication will be possible.
+ :param: authorizer is used to check whether an authenticated user is
+ allowed to perform operations. If it is None, it will use
+ a ClosedAuthorizer.
+ The identity parameter passed to authorizer.allow will
+ always have been obtained from a call to
+ IdentityClient.declared_identity.
+ :param: ops_store used to persistently store the association of
+ multi-op entities with their associated operations
+ when oven.macaroon is called with multiple operations.
+ :param: location holds the location to use when creating new macaroons.
'''
- self.version = version
- self.public_key = public_key
+
+ if checker is None:
+ checker = checkers.Checker()
+ root_keystore_for_ops = None
+ if root_key_store is not None:
+ def root_keystore_for_ops(ops):
+ return root_key_store
+
+ oven = Oven(key=key,
+ location=location,
+ locator=locator,
+ namespace=checker.namespace(),
+ root_keystore_for_ops=root_keystore_for_ops,
+ ops_store=ops_store)
+ self._oven = oven
+
+ self._checker = Checker(checker=checker, authorizer=authorizer,
+ identity_client=identity_client,
+ macaroon_opstore=oven)
+
+ @property
+ def oven(self):
+ return self._oven
+
+ @property
+ def checker(self):
+ return self._checker
diff --git a/macaroonbakery/checker.py b/macaroonbakery/checker.py
new file mode 100644
index 0000000..b73c92f
--- /dev/null
+++ b/macaroonbakery/checker.py
@@ -0,0 +1,409 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+from collections import namedtuple
+from threading import Lock
+
+
+import pyrfc3339
+
+import macaroonbakery
+import macaroonbakery.checkers as checkers
+
+
+class Op(namedtuple('Op', 'entity, action')):
+ ''' Op holds an entity and action to be authorized on that entity.
+ entity string holds the name of the entity to be authorized.
+
+ @param entity should not contain spaces and should
+ not start with the prefix "login" or "multi-" (conventionally,
+ entity names will be prefixed with the entity type followed
+ by a hyphen.
+ @param action string holds the action to perform on the entity,
+ such as "read" or "delete". It is up to the service using a checker
+ to define a set of operations and keep them consistent over time.
+ '''
+
+
+# LOGIN_OP represents a login (authentication) operation.
+# A macaroon that is associated with this operation generally
+# carries authentication information with it.
+LOGIN_OP = Op(entity='login', action='login')
+
+
+class Checker(object):
+ '''Checker implements an authentication and authorization checker.
+
+ It uses macaroons as authorization tokens but it is not itself responsible
+ for creating the macaroons
+ See the Oven type (TODO) for one way of doing that.
+ '''
+ def __init__(self, checker=checkers.Checker(),
+ authorizer=macaroonbakery.ClosedAuthorizer(),
+ identity_client=None,
+ macaroon_opstore=None):
+ '''
+ :param checker: a first party checker implementing a
+ :param authorizer (Authorizer): used to check whether an authenticated
+ user is allowed to perform operations.
+ The identity parameter passed to authorizer.allow will always have been
+ obtained from a call to identity_client.declared_identity.
+ :param identity_client (IdentityClient) used for interactions with the
+ external identity service used for authentication.
+ If this is None, no authentication will be possible.
+ :param macaroon_opstore (object with new_macaroon and macaroon_ops
+ method): used to retrieve macaroon root keys and other associated
+ information.
+ '''
+ self._first_party_caveat_checker = checker
+ self._authorizer = authorizer
+ if identity_client is None:
+ identity_client = macaroonbakery.NoIdentities()
+ self._identity_client = identity_client
+ self._macaroon_opstore = macaroon_opstore
+
+ def auth(self, mss):
+ ''' Returns a new AuthChecker instance using the given macaroons to
+ inform authorization decisions.
+ @param mss: a list of macaroon lists.
+ '''
+ return AuthChecker(parent=self,
+ macaroons=mss)
+
+ def namespace(self):
+ ''' Returns the namespace of the first party checker.
+ '''
+ return self._first_party_caveat_checker.namespace()
+
+
+class AuthChecker(object):
+ '''Authorizes operations with respect to a user's request.
+
+ The identity is authenticated only once, the first time any method
+ of the AuthChecker is called, using the context passed in then.
+
+ To find out any declared identity without requiring a login,
+ use allow(ctx); to require authentication but no additional operations,
+ use allow(ctx, LOGIN_OP).
+ '''
+ def __init__(self, parent, macaroons):
+ '''
+
+ :param parent (Checker): used to check first party caveats.
+ :param macaroons: a list of py macaroons
+ '''
+ self._macaroons = macaroons
+ self._init_errors = []
+ self._executed = False
+ self._identity = None
+ self._identity_caveats = []
+ self.parent = parent
+ self._conditions = None
+ self._mutex = Lock()
+
+ def _init(self, ctx):
+ with self._mutex:
+ if not self._executed:
+ self._init_once(ctx)
+ self._executed = True
+ if self._init_errors is not None and len(self._init_errors) > 0:
+ raise macaroonbakery.AuthInitError(self._init_errors[0])
+
+ def _init_once(self, ctx):
+ self._auth_indexes = {}
+ self._conditions = [None]*len(self._macaroons)
+ for i, ms in enumerate(self._macaroons):
+ try:
+ ops, conditions = self.parent._macaroon_opstore.macaroon_ops(
+ ms)
+ except macaroonbakery.VerificationError as exc:
+ self._init_errors.append(exc.args[0])
+ continue
+
+ # It's a valid macaroon (in principle - we haven't checked first
+ # party caveats).
+ self._conditions[i] = conditions
+ is_login = False
+ for op in ops:
+ if op == LOGIN_OP:
+ # Don't associate the macaroon with the login operation
+ # until we've verified that it is valid below
+ is_login = True
+ else:
+ if op not in self._auth_indexes:
+ self._auth_indexes[op] = []
+ self._auth_indexes[op].append(i)
+ if not is_login:
+ continue
+ # It's a login macaroon. Check the conditions now -
+ # all calls want to see the same authentication
+ # information so that callers have a consistent idea of
+ # the client's identity.
+ #
+ # If the conditions fail, we won't use the macaroon for
+ # identity, but we can still potentially use it for its
+ # other operations if the conditions succeed for those.
+ declared, err = self._check_conditions(ctx, LOGIN_OP, conditions)
+ if err is not None:
+ self._init_errors.append('cannot authorize login macaroon: ' +
+ err)
+ continue
+ if self._identity is not None:
+ # We've already found a login macaroon so ignore this one
+ # for the purposes of identity.
+ continue
+
+ try:
+ identity = self.parent._identity_client.declared_identity(
+ ctx, declared)
+ except macaroonbakery.IdentityError as exc:
+ self._init_errors.append(
+ 'cannot decode declared identity: {}'.format(exc.args[0]))
+ continue
+ if LOGIN_OP not in self._auth_indexes:
+ self._auth_indexes[LOGIN_OP] = []
+ self._auth_indexes[LOGIN_OP].append(i)
+ self._identity = identity
+
+ if self._identity is None:
+ # No identity yet, so try to get one based on the context.
+ try:
+ identity, cavs = self.parent.\
+ _identity_client.identity_from_context(ctx)
+ except macaroonbakery.IdentityError:
+ self._init_errors.append('could not determine identity')
+ if cavs is None:
+ cavs = []
+ self._identity, self._identity_caveats = identity, cavs
+ return None
+
+ def allow(self, ctx, ops):
+ ''' Checks that the authorizer's request is authorized to
+ perform all the given operations. Note that allow does not check
+ first party caveats - if there is more than one macaroon that may
+ authorize the request, it will choose the first one that does
+ regardless.
+
+ If all the operations are allowed, an AuthInfo is returned holding
+ details of the decision and any first party caveats that must be
+ checked before actually executing any operation.
+
+ If operations include LOGIN_OP, the request should contain an
+ authentication macaroon proving the client's identity. Once an
+ authentication macaroon is chosen, it will be used for all other
+ authorization requests.
+
+ If an operation was not allowed, an exception will be raised which may
+ be DischargeRequiredError holding the operations that remain to
+ be authorized in order to allow authorization to proceed.
+ :param: ctx AuthContext
+ :param: ops an array of Op
+ :return: an AuthInfo object.
+ '''
+ auth_info, _ = self.allow_any(ctx, ops)
+ return auth_info
+
+ def allow_any(self, ctx, ops):
+ ''' like allow except that it will authorize as many of the
+ operations as possible without requiring any to be authorized. If all
+ the operations succeeded, the array will be nil.
+
+ If any the operations failed, the returned error will be the same
+ that allow would return and each element in the returned slice will
+ hold whether its respective operation was allowed.
+
+ If all the operations succeeded, the returned slice will be None.
+
+ The returned AuthInfo will always be non-None.
+
+ The LOGIN_OP operation is treated specially - it is always required if
+ present in ops.
+ :param: ctx AuthContext
+ :param: ops an array of Op
+ :return: an AuthInfo object and the auth used as an array of int.
+ '''
+ authed, used = self._allow_any(ctx, ops)
+ return self._new_auth_info(used), authed
+
+ def _new_auth_info(self, used):
+ info = AuthInfo(identity=self._identity, macaroons=[])
+ for i, is_used in enumerate(used):
+ if is_used:
+ info.macaroons.append(self._macaroons[i])
+ return info
+
+ def _allow_any(self, ctx, ops):
+ self._init(ctx)
+ used = [False]*len(self._macaroons)
+ authed = [False]*len(ops)
+ num_authed = 0
+ errors = []
+ for i, op in enumerate(ops):
+ for mindex in self._auth_indexes.get(op, []):
+ _, err = self._check_conditions(ctx, op,
+ self._conditions[mindex])
+ if err is not None:
+ errors.append(err)
+ continue
+ authed[i] = True
+ num_authed += 1
+ used[mindex] = True
+ # Use the first authorized macaroon only.
+ break
+ if op == LOGIN_OP and not authed[i] and self._identity is not None:
+ # Allow LOGIN_OP when there's an authenticated user even
+ # when there's no macaroon that specifically authorizes it.
+ authed[i] = True
+ if self._identity is not None:
+ # We've authenticated as a user, so even if the operations didn't
+ # specifically require it, we add the login macaroon
+ # to the macaroons used.
+ # Note that the LOGIN_OP conditions have already been checked
+ # successfully in initOnceFunc so no need to check again.
+ # Note also that there may not be any macaroons if the
+ # identity client decided on an identity even with no
+ # macaroons.
+ for i in self._auth_indexes.get(LOGIN_OP, []):
+ used[i] = True
+ if num_authed == len(ops):
+ # All operations allowed.
+ return authed, used
+ # There are some unauthorized operations.
+ need = []
+ need_index = [0]*(len(ops)-num_authed)
+ for i, ok in enumerate(authed):
+ if not ok:
+ need_index[len(need)] = i
+ need.append(ops[i])
+
+ # Try to authorize the operations
+ # even if we haven't got an authenticated user.
+ oks, caveats = self.parent._authorizer.authorize(
+ ctx, self._identity, need)
+ still_need = []
+ for i, _ in enumerate(need):
+ if i < len(oks) and oks[i]:
+ authed[need_index[i]] = True
+ else:
+ still_need.append(ops[need_index[i]])
+ if len(still_need) == 0 and len(caveats) == 0:
+ # No more ops need to be authenticated and
+ # no caveats to be discharged.
+ return authed, used
+ if self._identity is None and len(self._identity_caveats) > 0:
+ raise macaroonbakery.DischargeRequiredError(
+ msg='authentication required',
+ ops=[LOGIN_OP],
+ cavs=self._identity_caveats)
+ if caveats is None or len(caveats) == 0:
+ all_errors = []
+ all_errors.extend(self._init_errors)
+ all_errors.extend(errors)
+ err = ''
+ if len(all_errors) > 0:
+ err = all_errors[0]
+ raise macaroonbakery.PermissionDenied(err)
+ raise macaroonbakery.DischargeRequiredError(
+ msg='some operations have extra caveats', ops=ops, cavs=caveats)
+
+ def allow_capability(self, ctx, ops):
+ '''Checks that the user is allowed to perform all the
+ given operations. If not, a discharge error will be raised.
+ If allow_capability succeeds, it returns a list of first party caveat
+ conditions that must be applied to any macaroon granting capability
+ to execute the operations. Those caveat conditions will not
+ include any declarations contained in login macaroons - the
+ caller must be careful not to mint a macaroon associated
+ with the LOGIN_OP operation unless they add the expected
+ declaration caveat too - in general, clients should not create
+ capabilities that grant LOGIN_OP rights.
+
+ The operations must include at least one non-LOGIN_OP operation.
+ '''
+ nops = 0
+ for op in ops:
+ if op != LOGIN_OP:
+ nops += 1
+ if nops == 0:
+ raise ValueError('no non-login operations required in capability')
+
+ _, used = self._allow_any(ctx, ops)
+ squasher = _CaveatSquasher()
+ for i, is_used in enumerate(used):
+ if not is_used:
+ continue
+ for cond in self._conditions[i]:
+ squasher.add(cond)
+ return squasher.final()
+
+ def _check_conditions(self, ctx, op, conds):
+ declared = checkers.infer_declared_from_conditions(
+ conds,
+ self.parent.namespace())
+ ctx = checkers.context_with_operations(ctx, [op.action])
+ ctx = checkers.context_with_declared(ctx, declared)
+ for cond in conds:
+ err = self.parent._first_party_caveat_checker.\
+ check_first_party_caveat(ctx, cond)
+ if err is not None:
+ return None, err
+ return declared, None
+
+
+class AuthInfo(namedtuple('AuthInfo', 'identity macaroons')):
+ '''AuthInfo information about an authorization decision.
+
+ :param: identity: holds information on the authenticated user as
+ returned identity_client. It may be None after a successful
+ authorization if LOGIN_OP access was not required.
+
+ :param: macaroons: holds all the macaroons that were used for the
+ authorization. Macaroons that were invalid or unnecessary are
+ not included.
+ '''
+
+
+class _CaveatSquasher(object):
+ ''' Rationalizes first party caveats created for a capability by:
+ - including only the earliest time-before caveat.
+ - excluding allow and deny caveats (operations are checked by
+ virtue of the operations associated with the macaroon).
+ - removing declared caveats.
+ - removing duplicates.
+ '''
+ def __init__(self, expiry=None, conds=None):
+ self._expiry = expiry
+ if conds is None:
+ conds = []
+ self._conds = conds
+
+ def add(self, cond):
+ if self._add(cond):
+ self._conds.append(cond)
+
+ def _add(self, cond):
+ try:
+ cond, args = checkers.parse_caveat(cond)
+ except ValueError:
+ # Be safe - if we can't parse the caveat, just leave it there.
+ return True
+
+ if cond == checkers.COND_TIME_BEFORE:
+ try:
+ et = pyrfc3339.parse(args)
+ except ValueError:
+ # Again, if it doesn't seem valid, leave it alone.
+ return True
+ if self._expiry is None or et <= self._expiry:
+ self._expiry = et
+ return False
+ elif cond in [checkers.COND_ALLOW,
+ checkers.COND_DENY, checkers.COND_DECLARED]:
+ return False
+ return True
+
+ def final(self):
+ if self._expiry is not None:
+ self._conds.append(
+ checkers.time_before_caveat(self._expiry).condition)
+ # Make deterministic and eliminate duplicates.
+ return sorted(set(self._conds))
diff --git a/macaroonbakery/checkers.py b/macaroonbakery/checkers.py
deleted file mode 100644
index 8d72eb9..0000000
--- a/macaroonbakery/checkers.py
+++ /dev/null
@@ -1,23 +0,0 @@
-# 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/checkers/__init__.py b/macaroonbakery/checkers/__init__.py
new file mode 100644
index 0000000..9f0b022
--- /dev/null
+++ b/macaroonbakery/checkers/__init__.py
@@ -0,0 +1,50 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+from macaroonbakery.checkers.conditions import (
+ STD_NAMESPACE, COND_DECLARED, COND_TIME_BEFORE, COND_ERROR, COND_ALLOW,
+ COND_DENY, COND_NEED_DECLARED
+)
+from macaroonbakery.checkers.caveat import (
+ allow_caveat, deny_caveat, declared_caveat, parse_caveat,
+ time_before_caveat, Caveat
+)
+from macaroonbakery.checkers.declared import (
+ context_with_declared, infer_declared, infer_declared_from_conditions,
+ need_declared_caveat
+)
+from macaroonbakery.checkers.operation import context_with_operations
+from macaroonbakery.checkers.namespace import Namespace, deserialize_namespace
+from macaroonbakery.checkers.time import context_with_clock
+from macaroonbakery.checkers.checkers import (
+ Checker, CheckerInfo, RegisterError
+)
+from macaroonbakery.checkers.auth_context import AuthContext, ContextKey
+
+__all__ = [
+ 'AuthContext',
+ 'Caveat',
+ 'Checker',
+ 'CheckerInfo',
+ 'COND_ALLOW',
+ 'COND_DECLARED',
+ 'COND_DENY',
+ 'COND_ERROR',
+ 'COND_NEED_DECLARED',
+ 'COND_TIME_BEFORE',
+ 'ContextKey',
+ 'STD_NAMESPACE',
+ 'Namespace',
+ 'RegisterError',
+ 'allow_caveat',
+ 'context_with_declared',
+ 'context_with_operations',
+ 'context_with_clock',
+ 'declared_caveat',
+ 'deny_caveat',
+ 'deserialize_namespace',
+ 'infer_declared',
+ 'infer_declared_from_conditions',
+ 'need_declared_caveat',
+ 'parse_caveat',
+ 'time_before_caveat',
+]
diff --git a/macaroonbakery/checkers/auth_context.py b/macaroonbakery/checkers/auth_context.py
new file mode 100644
index 0000000..dceb015
--- /dev/null
+++ b/macaroonbakery/checkers/auth_context.py
@@ -0,0 +1,58 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+import collections
+
+
+class AuthContext(collections.Mapping):
+ ''' Holds a set of keys and values relevant to authorization.
+
+ It is passed as an argument to authorization checkers, so that the checkers
+ can access information about the context of the authorization request.
+ It is immutable - values can only be added by copying the whole thing.
+ '''
+ def __init__(self, somedict=None):
+ if somedict is None:
+ somedict = {}
+ self._dict = dict(somedict)
+ self._hash = None
+
+ def with_value(self, key, val):
+ ''' Return a copy of the AuthContext object with the given key and
+ value added.
+ '''
+ new_dict = dict(self._dict)
+ new_dict[key] = val
+ return AuthContext(new_dict)
+
+ def __getitem__(self, key):
+ return self._dict[key]
+
+ def __len__(self):
+ return len(self._dict)
+
+ def __iter__(self):
+ return iter(self._dict)
+
+ def __hash__(self):
+ if self._hash is None:
+ self._hash = hash(frozenset(self._dict.items()))
+ return self._hash
+
+ def __eq__(self, other):
+ return self._dict == other._dict
+
+
+class ContextKey(object):
+ '''Provides a unique key suitable for use as a key into AuthContext.'''
+
+ def __init__(self, name):
+ '''Creates a context key using the given name. The name is
+ only for informational purposes.
+ '''
+ self._name = name
+
+ def __str__(self):
+ return '%s#%#x' % (self._name, id(self))
+
+ def __repr__(self):
+ return 'context_key(%r, %#x)' % (self._name, id(self))
diff --git a/macaroonbakery/checkers/caveat.py b/macaroonbakery/checkers/caveat.py
new file mode 100644
index 0000000..a1e564e
--- /dev/null
+++ b/macaroonbakery/checkers/caveat.py
@@ -0,0 +1,125 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+import collections
+
+import pyrfc3339
+
+from macaroonbakery.checkers.conditions import (
+ STD_NAMESPACE, COND_TIME_BEFORE, COND_ERROR, COND_DENY, COND_ALLOW,
+ COND_DECLARED
+)
+
+
+class Caveat(collections.namedtuple('Caveat', 'condition location namespace')):
+ '''Represents a condition that must be true for a check to complete
+ successfully.
+
+ If location is provided, the caveat must be discharged by
+ a third party at the given location (a URL string).
+
+ The namespace parameter holds the namespace URI string of the
+ condition - if it is provided, it will be converted to a namespace prefix
+ before adding to the macaroon.
+ '''
+ __slots__ = ()
+
+ def __new__(cls, condition, location=None, namespace=None):
+ return super(Caveat, cls).__new__(cls, condition, location, namespace)
+
+
+def declared_caveat(key, value):
+ '''Returns a "declared" caveat asserting that the given key is
+ set to the given value.
+
+ If a macaroon has exactly one first party caveat asserting the value of a
+ particular key, then infer_declared will be able to infer the value, and
+ then the check will allow the declared value if it has the value
+ specified here.
+
+ If the key is empty or contains a space, it will return an error caveat.
+ '''
+ if key.find(' ') >= 0 or key == '':
+ return error_caveat('invalid caveat \'declared\' key "{}"'.format(key))
+ return _first_party(COND_DECLARED, key + ' ' + value)
+
+
+def error_caveat(f):
+ '''Returns a caveat that will never be satisfied, holding f as the text of
+ the caveat.
+
+ This should only be used for highly unusual conditions that are never
+ expected to happen in practice, such as a malformed key that is
+ conventionally passed as a constant. It's not a panic but you should
+ only use it in cases where a panic might possibly be appropriate.
+
+ This mechanism means that caveats can be created without error
+ checking and a later systematic check at a higher level (in the
+ bakery package) can produce an error instead.
+ '''
+ return _first_party(COND_ERROR, f)
+
+
+def allow_caveat(ops):
+ ''' Returns a caveat that will deny attempts to use the macaroon to perform
+ any operation other than those listed. Operations must not contain a space.
+ '''
+ if ops is None or len(ops) == 0:
+ return error_caveat('no operations allowed')
+ return _operation_caveat(COND_ALLOW, ops)
+
+
+def deny_caveat(ops):
+ '''Returns a caveat that will deny attempts to use the macaroon to perform
+ any of the listed operations. Operations must not contain a space.
+ '''
+ return _operation_caveat(COND_DENY, ops)
+
+
+def _operation_caveat(cond, ops):
+ ''' Helper for allow_caveat and deny_caveat.
+
+ It checks that all operation names are valid before creating the caveat.
+ '''
+ for op in ops:
+ if op.find(' ') != -1:
+ return error_caveat('invalid operation name "{}"'.format(op))
+ return _first_party(cond, ' '.join(ops))
+
+
+def time_before_caveat(t):
+ '''Return a caveat that specifies that the time that it is checked at
+ should be before t.
+ :param t is a a UTC date in - use datetime.utcnow, not datetime.now
+ '''
+
+ return _first_party(COND_TIME_BEFORE,
+ pyrfc3339.generate(t, accept_naive=True,
+ microseconds=True))
+
+
+def parse_caveat(cav):
+ ''' Parses a caveat into an identifier, identifying the checker that should
+ be used, and the argument to the checker (the rest of the string).
+
+ The identifier is taken from all the characters before the first
+ space character.
+ :return two string, identifier and arg
+ '''
+ if cav == '':
+ raise ValueError('empty caveat')
+ try:
+ i = cav.index(' ')
+ except ValueError:
+ return cav, ''
+ if i == 0:
+ raise ValueError('caveat starts with space character')
+ return cav[0:i], cav[i + 1:]
+
+
+def _first_party(name, arg):
+ condition = name
+ if arg != '':
+ condition += ' ' + arg
+
+ return Caveat(condition=condition,
+ namespace=STD_NAMESPACE)
diff --git a/macaroonbakery/checkers/checkers.py b/macaroonbakery/checkers/checkers.py
new file mode 100644
index 0000000..776b50b
--- /dev/null
+++ b/macaroonbakery/checkers/checkers.py
@@ -0,0 +1,243 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+import abc
+from collections import namedtuple
+from datetime import datetime
+
+import pyrfc3339
+import pytz
+
+from macaroonbakery.checkers.declared import DECLARED_KEY
+from macaroonbakery.checkers.time import TIME_KEY
+from macaroonbakery.checkers.operation import OP_KEY
+from macaroonbakery.checkers.namespace import Namespace
+from macaroonbakery.checkers.caveat import parse_caveat
+from macaroonbakery.checkers.conditions import (
+ STD_NAMESPACE, COND_DECLARED, COND_ALLOW, COND_DENY, COND_ERROR,
+ COND_TIME_BEFORE
+)
+from macaroonbakery.checkers.utils import condition_with_prefix
+
+
+class RegisterError(Exception):
+ '''Raised when a condition cannot be registered with a Checker.'''
+ pass
+
+
+class FirstPartyCaveatChecker(object):
+ '''Used to check first party caveats for validity with respect to
+ information in the provided context.
+
+ If the caveat kind was not recognised, the checker should return
+ ErrCaveatNotRecognized.
+ '''
+ __metaclass__ = abc.ABCMeta
+
+ @abc.abstractmethod
+ def check_first_party_caveat(self, ctx, caveat):
+ ''' Checks that the given caveat condition is valid with respect to
+ the given context information.
+ :param ctx: an Auth context
+ :param caveat a string
+ '''
+ raise NotImplementedError('check_first_party_caveat method must be '
+ 'defined in subclass')
+
+ def namespace(self):
+ ''' Returns the namespace associated with the caveat checker.
+ '''
+ raise NotImplementedError('namespace method must be '
+ 'defined in subclass')
+
+
+class Checker(FirstPartyCaveatChecker):
+ ''' Holds a set of checkers for first party caveats.
+ '''
+
+ def __init__(self, namespace=None, include_std_checkers=True):
+ if namespace is None:
+ namespace = Namespace()
+ self._namespace = namespace
+ self._checkers = {}
+ if include_std_checkers:
+ self.register_std()
+
+ def check_first_party_caveat(self, ctx, cav):
+ ''' Checks the caveat against all registered caveat conditions.
+ :return: error message string if any or None
+ '''
+ try:
+ cond, arg = parse_caveat(cav)
+ except ValueError as ex:
+ # If we can't parse it, perhaps it's in some other format,
+ # return a not-recognised error.
+ return 'cannot parse caveat "{}": {}'.format(cav, ex.args[0])
+ checker = self._checkers.get(cond)
+ if checker is None:
+ return 'caveat "{}" not satisfied: caveat not recognized'.format(
+ cav)
+ err = checker.check(ctx, cond, arg)
+ if err is not None:
+ return 'caveat "{}" not satisfied: {}'.format(cav, err)
+
+ def namespace(self):
+ ''' Returns the namespace associated with the Checker.
+ '''
+ return self._namespace
+
+ def info(self):
+ ''' Returns information on all the registered checkers.
+
+ Sorted by namespace and then name
+ :returns a list of CheckerInfo
+ '''
+ return sorted(self._checkers.values(), key=lambda x: (x.ns, x.name))
+
+ def register(self, cond, uri, check):
+ ''' Registers the given condition(string) in the given namespace
+ uri (string) to be checked with the given check function.
+ The check function checks a caveat by passing an auth context, a cond
+ parameter(string) that holds the caveat condition including any
+ namespace prefix and an arg parameter(string) that hold any additional
+ caveat argument text. It will return any error as string otherwise
+ None.
+
+ It will raise a ValueError if the namespace is not registered or
+ if the condition has already been registered.
+ '''
+ if check is None:
+ raise RegisterError(
+ 'no check function registered for namespace {} when '
+ 'registering condition {}'.format(uri, cond))
+
+ prefix = self._namespace.resolve(uri)
+ if prefix is None:
+ raise RegisterError('no prefix registered for namespace {} when '
+ 'registering condition {}'.format(uri, cond))
+
+ if prefix == '' and cond.find(':') >= 0:
+ raise RegisterError(
+ 'caveat condition {} in namespace {} contains a colon but its'
+ ' prefix is empty'.format(cond, uri))
+
+ full_cond = condition_with_prefix(prefix, cond)
+ info = self._checkers.get(full_cond)
+ if info is not None:
+ raise RegisterError(
+ 'checker for {} (namespace {}) already registered in '
+ 'namespace {}'.format(full_cond, uri, info.ns))
+ self._checkers[full_cond] = CheckerInfo(
+ check=check,
+ ns=uri,
+ name=cond,
+ prefix=prefix)
+
+ def register_std(self):
+ ''' Registers all the standard checkers in the given checker.
+
+ If not present already, the standard checkers schema (STD_NAMESPACE) is
+ added to the checker's namespace with an empty prefix.
+ '''
+ self._namespace.register(STD_NAMESPACE, '')
+ for cond in _ALL_CHECKERS:
+ self.register(cond, STD_NAMESPACE, _ALL_CHECKERS[cond])
+
+
+class CheckerInfo(namedtuple('CheckInfo', 'prefix name ns check')):
+ '''CheckerInfo holds information on a registered checker.
+ '''
+ __slots__ = ()
+
+ def __new__(cls, prefix, name, ns, check=None):
+ '''
+ :param check holds the actual checker function which takes an auth
+ context and a condition and arg string as arguments.
+ :param prefix holds the prefix for the checker condition as string.
+ :param name holds the name of the checker condition as string.
+ :param ns holds the namespace URI for the checker's schema as
+ Namespace.
+ '''
+ return super(CheckerInfo, cls).__new__(cls, prefix, name, ns, check)
+
+
+def _check_time_before(ctx, cond, arg):
+ clock = ctx.get(TIME_KEY)
+ if clock is None:
+ now = pytz.UTC.localize(datetime.utcnow())
+ else:
+ now = clock.utcnow()
+
+ try:
+ if pyrfc3339.parse(arg) <= now:
+ return 'macaroon has expired'
+ except ValueError:
+ return 'cannot parse "{}" as RFC 3339'.format(arg)
+ return None
+
+
+def _check_declared(ctx, cond, arg):
+ parts = arg.split(' ', 1)
+ if len(parts) != 2:
+ return 'declared caveat has no value'
+ attrs = ctx.get(DECLARED_KEY, {})
+ val = attrs.get(parts[0])
+ if val is None:
+ return 'got {}=null, expected "{}"'.format(parts[0], parts[1])
+
+ if val != parts[1]:
+ return 'got {}="{}", expected "{}"'.format(parts[0], val, parts[1])
+ return None
+
+
+def _check_error(ctx, cond, arg):
+ return 'bad caveat'
+
+
+def _check_allow(ctx, cond, arg):
+ return _check_operations(ctx, True, arg)
+
+
+def _check_deny(ctx, cond, arg):
+ return _check_operations(ctx, False, arg)
+
+
+def _check_operations(ctx, need_ops, arg):
+ ''' Checks an allow or a deny caveat. The need_ops parameter specifies
+ whether we require all the operations in the caveat to be declared in
+ the context.
+ '''
+ ctx_ops = ctx.get(OP_KEY, [])
+ if len(ctx_ops) == 0:
+ if need_ops:
+ f = arg.split()
+ if len(f) == 0:
+ return 'no operations allowed'
+ return '{} not allowed'.format(f[0])
+ return None
+
+ fields = arg.split()
+ for op in ctx_ops:
+ err = _check_op(op, need_ops, fields)
+ if err is not None:
+ return err
+ return None
+
+
+def _check_op(ctx_op, need_op, fields):
+ found = False
+ for op in fields:
+ if op == ctx_op:
+ found = True
+ break
+ if found != need_op:
+ return '{} not allowed'.format(ctx_op)
+ return None
+
+
+_ALL_CHECKERS = {
+ COND_TIME_BEFORE: _check_time_before,
+ COND_DECLARED: _check_declared,
+ COND_ERROR: _check_error,
+ COND_ALLOW: _check_allow,
+ COND_DENY: _check_deny,
+}
diff --git a/macaroonbakery/checkers/conditions.py b/macaroonbakery/checkers/conditions.py
new file mode 100644
index 0000000..74e863e
--- /dev/null
+++ b/macaroonbakery/checkers/conditions.py
@@ -0,0 +1,17 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+
+# StdNamespace holds the URI of the standard checkers schema.
+STD_NAMESPACE = 'std'
+
+# Constants for all the standard caveat conditions.
+# First and third party caveat conditions are both defined here,
+# even though notionally they exist in separate name spaces.
+COND_DECLARED = 'declared'
+COND_TIME_BEFORE = 'time-before'
+COND_ERROR = 'error'
+COND_ALLOW = 'allow'
+COND_DENY = 'deny'
+
+
+COND_NEED_DECLARED = 'need-declared'
diff --git a/macaroonbakery/checkers/declared.py b/macaroonbakery/checkers/declared.py
new file mode 100644
index 0000000..78a6181
--- /dev/null
+++ b/macaroonbakery/checkers/declared.py
@@ -0,0 +1,82 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+from macaroonbakery.checkers.namespace import Namespace
+from macaroonbakery.checkers.caveat import parse_caveat, Caveat, error_caveat
+from macaroonbakery.checkers.conditions import (
+ COND_DECLARED, COND_NEED_DECLARED, STD_NAMESPACE
+)
+from macaroonbakery.checkers.auth_context import ContextKey
+
+DECLARED_KEY = ContextKey('declared-key')
+
+
+def infer_declared(ms, namespace=None):
+ '''Retrieves any declared information from the given macaroons and returns
+ it as a key-value map.
+ Information is declared with a first party caveat as created by
+ declared_caveat.
+
+ If there are two caveats that declare the same key with different values,
+ the information is omitted from the map. When the caveats are later
+ checked, this will cause the check to fail.
+ namespace is the Namespace used to retrieve the prefix associated to the
+ uri, if None it will use the STD_NAMESPACE only.
+ '''
+ conditions = []
+ for m in ms:
+ for cav in m.caveats:
+ if cav.location is None or cav.location == '':
+ conditions.append(cav.caveat_id_bytes.decode('utf-8'))
+ return infer_declared_from_conditions(conditions, namespace)
+
+
+def infer_declared_from_conditions(conds, namespace=None):
+ ''' like infer_declared except that it is passed a set of first party
+ caveat conditions as a list of string rather than a set of macaroons.
+ '''
+ conflicts = []
+ # If we can't resolve that standard namespace, then we'll look for
+ # just bare "declared" caveats which will work OK for legacy
+ # macaroons with no namespace.
+ if namespace is None:
+ namespace = Namespace()
+ prefix = namespace.resolve(STD_NAMESPACE)
+ if prefix is None:
+ prefix = ''
+ declared_cond = prefix + COND_DECLARED
+
+ info = {}
+ for cond in conds:
+ try:
+ name, rest = parse_caveat(cond)
+ except ValueError:
+ name, rest = '', ''
+ if name != declared_cond:
+ continue
+ parts = rest.split(' ', 1)
+ if len(parts) != 2:
+ continue
+ key, val = parts[0], parts[1]
+ old_val = info.get(key)
+ if old_val is not None and old_val != val:
+ conflicts.append(key)
+ continue
+ info[key] = val
+ for key in set(conflicts):
+ del info[key]
+ return info
+
+
+def context_with_declared(ctx, declared):
+ ''' Returns a context with attached declared information,
+ as returned from infer_declared.
+ '''
+ return ctx.with_value(DECLARED_KEY, declared)
+
+
+def need_declared_caveat(cav, keys):
+ if cav.location == '':
+ return error_caveat('need-declared caveat is not third-party')
+ return Caveat(location=cav.location,
+ condition=(COND_NEED_DECLARED + ' ' + ','.join(keys)
+ + ' ' + cav.condition))
diff --git a/macaroonbakery/namespace.py b/macaroonbakery/checkers/namespace.py
index ae0fa91..31e8801 100644
--- a/macaroonbakery/namespace.py
+++ b/macaroonbakery/checkers/namespace.py
@@ -1,11 +1,9 @@
# 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'
+from macaroonbakery.checkers.utils import condition_with_prefix
+from macaroonbakery.checkers.caveat import error_caveat
class Namespace:
@@ -16,6 +14,7 @@ class Namespace:
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:
@@ -26,13 +25,13 @@ class Namespace:
'''Returns the namespace representation as returned by serialize
:return: str
'''
- return self.serialize().decode('utf-8')
+ return self.serialize_text().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.
+ def serialize_text(self):
+ '''Returns a serialized form of the Namepace.
All the elements in the namespace are sorted by
URI, joined to the associated prefix with a colon and
@@ -45,7 +44,7 @@ class Namespace:
data = []
for uri in od:
data.append(uri + ':' + od[uri])
- return six.b(' '.join(data))
+ return ' '.join(data).encode('utf-8')
def register(self, uri, prefix):
'''Registers the given URI and associates it with the given prefix.
@@ -75,6 +74,39 @@ class Namespace:
'''
return self._uri_to_prefix.get(uri)
+ def resolve_caveat(self, cav):
+ ''' Resolves the given caveat(string) by using resolve to map from its
+ schema namespace to the appropriate prefix.
+ If there is no registered prefix for the namespace, it returns an error
+ caveat.
+ If cav.namespace is empty or cav.location is non-empty, it returns cav
+ unchanged.
+
+ It does not mutate ns and may be called concurrently with other
+ non-mutating Namespace methods.
+ :return: Caveat object
+ '''
+ # TODO: If a namespace isn't registered, try to resolve it by
+ # resolving it to the latest compatible version that is
+ # registered.
+ if cav.namespace == '' or cav.location != '':
+ return cav
+
+ prefix = self.resolve(cav.namespace)
+ if prefix is None:
+ err_cav = error_caveat(
+ 'caveat {} in unregistered namespace {}'.format(
+ cav.condition, cav.namespace))
+ if err_cav.namespace != cav.namespace:
+ prefix = self.resolve(err_cav.namespace)
+ if prefix is None:
+ prefix = ''
+ cav = err_cav
+ if prefix != '':
+ cav.condition = condition_with_prefix(prefix, cav.condition)
+ cav.namespace = ''
+ return cav
+
def is_valid_schema_uri(uri):
'''Reports if uri is suitable for use as a namespace schema URI.
@@ -107,9 +139,27 @@ def deserialize_namespace(data):
'''
if isinstance(data, bytes):
data = data.decode('utf-8')
- kvs = data.split(' ')
+ kvs = data.split()
uri_to_prefix = {}
for kv in kvs:
- k, v = kv.split(':')
- uri_to_prefix[k] = v
+ i = kv.rfind(':')
+ if i == -1:
+ raise ValueError('no colon in namespace '
+ 'field {}'.format(repr(kv)))
+ uri, prefix = kv[0:i], kv[i + 1:]
+ if not is_valid_schema_uri(uri):
+ # Currently this can't happen because the only invalid URIs
+ # are those which contain a space
+ raise ValueError(
+ 'invalid URI {} in namespace '
+ 'field {}'.format(repr(uri), repr(kv)))
+ if not is_valid_prefix(prefix):
+ raise ValueError(
+ 'invalid prefix {} in namespace field'
+ ' {}'.format(repr(prefix), repr(kv)))
+ if uri in uri_to_prefix:
+ raise ValueError(
+ 'duplicate URI {} in '
+ 'namespace {}'.format(repr(uri), repr(data)))
+ uri_to_prefix[uri] = prefix
return Namespace(uri_to_prefix)
diff --git a/macaroonbakery/checkers/operation.py b/macaroonbakery/checkers/operation.py
new file mode 100644
index 0000000..a3b3805
--- /dev/null
+++ b/macaroonbakery/checkers/operation.py
@@ -0,0 +1,17 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+from macaroonbakery.checkers.auth_context import ContextKey
+
+OP_KEY = ContextKey('op-key')
+
+
+def context_with_operations(ctx, ops):
+ ''' Returns a context(AuthContext) which is associated with all the given
+ operations (list of string). It will be based on the auth context
+ passed in as ctx.
+
+ An allow caveat will succeed only if one of the allowed operations is in
+ ops; a deny caveat will succeed only if none of the denied operations are
+ in ops.
+ '''
+ return ctx.with_value(OP_KEY, ops)
diff --git a/macaroonbakery/checkers/time.py b/macaroonbakery/checkers/time.py
new file mode 100644
index 0000000..052d983
--- /dev/null
+++ b/macaroonbakery/checkers/time.py
@@ -0,0 +1,18 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+from macaroonbakery.checkers.auth_context import ContextKey
+
+
+TIME_KEY = ContextKey('time-key')
+
+
+def context_with_clock(ctx, clock):
+ ''' Returns a copy of ctx with a key added that associates it with the given
+ clock implementation, which will be used by the time-before checker
+ to determine the current time.
+ The clock should have a utcnow method that returns the current time
+ as a datetime value in UTC.
+ '''
+ if clock is None:
+ return ctx
+ return ctx.with_value(TIME_KEY, clock)
diff --git a/macaroonbakery/checkers/utils.py b/macaroonbakery/checkers/utils.py
new file mode 100644
index 0000000..f2e51b1
--- /dev/null
+++ b/macaroonbakery/checkers/utils.py
@@ -0,0 +1,13 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+
+
+def condition_with_prefix(prefix, condition):
+ '''Returns the given string prefixed by the given prefix.
+
+ If the prefix is non-empty, a colon is used to separate them.
+ '''
+ if prefix == '':
+ return condition
+
+ return prefix + ':' + condition
diff --git a/macaroonbakery/codec.py b/macaroonbakery/codec.py
index f5cc493..d9340b7 100644
--- a/macaroonbakery/codec.py
+++ b/macaroonbakery/codec.py
@@ -1,15 +1,13 @@
# Copyright 2017 Canonical Ltd.
# Licensed under the LGPLv3, see LICENCE file for details.
-
import base64
import json
-from macaroonbakery import namespace
-from nacl.public import Box, PublicKey
-from nacl.encoding import Base64Encoder
import six
+import nacl.public
-from macaroonbakery import bakery
+import macaroonbakery
+import macaroonbakery.checkers as checkers
_PUBLIC_KEY_PREFIX_LEN = 4
_KEY_LEN = 32
@@ -35,44 +33,42 @@ def encode_caveat(condition, root_key, third_party_info, key, ns):
@param ns not used yet
@return bytes
'''
- if third_party_info.version == bakery.BAKERY_V1:
+ if third_party_info.version == macaroonbakery.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):
+ if (third_party_info.version == macaroonbakery.BAKERY_V2 or
+ third_party_info.version == macaroonbakery.BAKERY_V3):
return _encode_caveat_v2_v3(third_party_info.version, condition,
- root_key, third_party_info.public_key, key,
- ns)
+ 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
+ The third_party_pub_key key represents the PublicKey of the third party
we're encrypting the caveat for; the key is the public/private key pair of
the party that's adding the caveat.
@param condition string
@param root_key bytes
- @param third_party_pub_key nacl public key
- @param key nacl private key
+ @param third_party_pub_key (PublicKey)
+ @param key (PrivateKey)
@return a base64 encoded bytes
'''
plain_data = json.dumps({
'RootKey': base64.b64encode(root_key).decode('ascii'),
'Condition': condition
})
- box = Box(key, third_party_pub_key)
+ box = nacl.public.Box(key.key, third_party_pub_key.key)
encrypted = box.encrypt(six.b(plain_data))
- nonce = encrypted[0:Box.NONCE_SIZE]
- encrypted = encrypted[Box.NONCE_SIZE:]
+ nonce = encrypted[0:nacl.public.Box.NONCE_SIZE]
+ encrypted = encrypted[nacl.public.Box.NONCE_SIZE:]
return base64.b64encode(six.b(json.dumps({
- 'ThirdPartyPublicKey': third_party_pub_key.encode(
- Base64Encoder).decode('ascii'),
- 'FirstPartyPublicKey': key.public_key.encode(
- Base64Encoder).decode('ascii'),
+ 'ThirdPartyPublicKey': third_party_pub_key.encode().decode('ascii'),
+ 'FirstPartyPublicKey': key.public_key.encode().decode('ascii'),
'Nonce': base64.b64encode(nonce).decode('ascii'),
'Id': base64.b64encode(encrypted).decode('ascii')
})))
@@ -103,17 +99,17 @@ def _encode_caveat_v2_v3(version, condition, root_key, third_party_pub_key,
condition [rest of encrypted part]
'''
ns_data = bytearray()
- if version >= bakery.BAKERY_V3:
- ns_data = ns.serialize()
+ if version >= macaroonbakery.BAKERY_V3:
+ ns_data = ns.serialize_text()
data = bytearray()
data.append(version)
- data.extend(third_party_pub_key.encode()[:_PUBLIC_KEY_PREFIX_LEN])
- data.extend(key.public_key.encode()[:])
+ data.extend(third_party_pub_key.encode(raw=True)[:_PUBLIC_KEY_PREFIX_LEN])
+ data.extend(key.public_key.encode(raw=True)[:])
secret = _encode_secret_part_v2_v3(version, condition, root_key, ns_data)
- box = Box(key, third_party_pub_key)
+ box = nacl.public.Box(key.key, third_party_pub_key.key)
encrypted = box.encrypt(secret)
- nonce = encrypted[0:Box.NONCE_SIZE]
- encrypted = encrypted[Box.NONCE_SIZE:]
+ nonce = encrypted[0:nacl.public.Box.NONCE_SIZE]
+ encrypted = encrypted[nacl.public.Box.NONCE_SIZE:]
data.extend(nonce[:])
data.extend(encrypted)
return bytes(data)
@@ -133,10 +129,10 @@ def _encode_secret_part_v2_v3(version, condition, root_key, ns):
'''
data = bytearray()
data.append(version)
- _encode_uvarint(len(root_key), data)
+ encode_uvarint(len(root_key), data)
data.extend(root_key)
- if version >= bakery.BAKERY_V3:
- _encode_uvarint(len(ns), data)
+ if version >= macaroonbakery.BAKERY_V3:
+ encode_uvarint(len(ns), data)
data.extend(ns)
data.extend(condition.encode('utf-8'))
return bytes(data)
@@ -150,7 +146,7 @@ def decode_caveat(key, caveat):
@return ThirdPartyCaveatInfo
'''
if len(caveat) == 0:
- raise ValueError('empty third party caveat')
+ raise macaroonbakery.VerificationError('empty third party caveat')
first = caveat[:1]
if first == b'e':
@@ -158,16 +154,17 @@ def decode_caveat(key, caveat):
# 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 (first_as_int == macaroonbakery.BAKERY_V2 or
+ first_as_int == macaroonbakery.BAKERY_V3):
if (len(caveat) < _VERSION3_CAVEAT_MIN_LEN
- and first_as_int == bakery.BAKERY_V3):
+ and first_as_int == macaroonbakery.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(
+ raise macaroonbakery.VerificationError(
'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')
+ raise macaroonbakery.VerificationError('unknown version for caveat')
def _decode_caveat_v1(key, caveat):
@@ -177,12 +174,11 @@ def _decode_caveat_v1(key, caveat):
@param caveat a base64 encoded JSON string.
'''
- from macaroonbakery import macaroon
-
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:
+ tp_public_key = nacl.public.PublicKey(
+ base64.b64decode(wrapper['ThirdPartyPublicKey']))
+ if key.public_key.key != tp_public_key:
raise Exception('public key mismatch') # TODO
if wrapper.get('FirstPartyPublicKey', None) is None:
@@ -192,86 +188,83 @@ def _decode_caveat_v1(key, caveat):
secret = base64.b64decode(wrapper.get('Id'))
nonce = base64.b64decode(wrapper.get('Nonce'))
- fp_public_key = PublicKey(base64.b64decode(
+ fp_public_key = nacl.public.PublicKey(base64.b64decode(
wrapper.get('FirstPartyPublicKey')))
- box = Box(key, fp_public_key)
+ box = nacl.public.Box(key.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()
+ fp_key = nacl.public.PublicKey(
+ base64.b64decode(wrapper.get('FirstPartyPublicKey')))
+ return macaroonbakery.ThirdPartyCaveatInfo(
+ condition=record.get('Condition'),
+ first_party_public_key=macaroonbakery.PublicKey(fp_key),
+ third_party_key_pair=key,
+ root_key=base64.b64decode(record.get('RootKey')),
+ caveat=caveat,
+ version=macaroonbakery.BAKERY_V1,
+ namespace=macaroonbakery.legacy_namespace()
)
def _decode_caveat_v2_v3(version, key, caveat):
'''Decodes a version 2 or version 3 caveat.
'''
- from macaroonbakery import macaroon
-
if (len(caveat) < 1 + _PUBLIC_KEY_PREFIX_LEN +
- _KEY_LEN + Box.NONCE_SIZE + 16):
- raise ValueError('caveat id too short')
+ _KEY_LEN + nacl.public.Box.NONCE_SIZE + 16):
+ raise macaroonbakery.VerificationError('caveat id too short')
original_caveat = caveat
caveat = caveat[1:] # skip version (already checked)
pk_prefix = caveat[:_PUBLIC_KEY_PREFIX_LEN]
caveat = caveat[_PUBLIC_KEY_PREFIX_LEN:]
- if key.public_key.encode()[:_PUBLIC_KEY_PREFIX_LEN] != pk_prefix:
- raise ValueError('public key mismatch')
+ if key.public_key.encode(raw=True)[:_PUBLIC_KEY_PREFIX_LEN] != pk_prefix:
+ raise macaroonbakery.VerificationError('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)
+ nonce = caveat[:nacl.public.Box.NONCE_SIZE]
+ caveat = caveat[nacl.public.Box.NONCE_SIZE:]
+ fp_public_key = nacl.public.PublicKey(first_party_pub)
+ box = nacl.public.Box(key.key, fp_public_key)
data = box.decrypt(caveat, nonce)
root_key, condition, ns = _decode_secret_part_v2_v3(version, data)
- return macaroon.ThirdPartyCaveatInfo(
- condition.decode('utf-8'),
- fp_public_key,
- key,
- root_key,
- original_caveat,
- version,
- ns
+ return macaroonbakery.ThirdPartyCaveatInfo(
+ condition=condition.decode('utf-8'),
+ first_party_public_key=macaroonbakery.PublicKey(fp_public_key),
+ third_party_key_pair=key,
+ root_key=root_key,
+ caveat=original_caveat,
+ version=version,
+ namespace=ns
)
def _decode_secret_part_v2_v3(version, data):
- from macaroonbakery import macaroon
-
if len(data) < 1:
- raise ValueError('secret part too short')
+ raise macaroonbakery.VerificationError('secret part too short')
got_version = six.byte2int(data[:1])
data = data[1:]
if version != got_version:
- raise ValueError(
+ raise macaroonbakery.VerificationError(
'unexpected secret part version, got {} want {}'.format(
got_version, version))
- root_key_length, read = _decode_uvarint(data)
+ 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)
+ if version >= macaroonbakery.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)
+ ns = checkers.deserialize_namespace(ns_data)
else:
- ns = macaroon.legacy_namespace()
+ ns = macaroonbakery.legacy_namespace()
return root_key, data, ns
-def _encode_uvarint(n, data):
+def encode_uvarint(n, data):
'''encodes integer into variable-length format into data.'''
if n < 0:
raise ValueError('only support positive integer')
@@ -284,8 +277,8 @@ def _encode_uvarint(n, data):
data.append(this_byte | 128)
-def _decode_uvarint(data):
- '''Decode a variable -length integer.
+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.
diff --git a/macaroonbakery/discharge.py b/macaroonbakery/discharge.py
new file mode 100644
index 0000000..d4c0e5a
--- /dev/null
+++ b/macaroonbakery/discharge.py
@@ -0,0 +1,210 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+import abc
+from collections import namedtuple
+
+import macaroonbakery
+import macaroonbakery.checkers as checkers
+
+
+def discharge_all(ctx, m, get_discharge, local_key=None):
+ '''Gathers discharge macaroons for all the third party caveats in m
+ (and any subsequent caveats required by those) using get_discharge to
+ acquire each discharge macaroon.
+ The local_key parameter may optionally hold the key of the client, in
+ which case it will be used to discharge any third party caveats with the
+ special location "local". In this case, the caveat itself must be "true".
+ This can be used be a server to ask a client to prove ownership of the
+ private key.
+ It returns a list of macaroon with m as the first element, followed by all
+ the discharge macaroons.
+ All the discharge macaroons will be bound to the primary macaroon.
+ The get_discharge function is passed a context (AuthContext),
+ the caveat(Caveat) to be discharged and encrypted_caveat (bytes)will be
+ passed the external caveat payload found in m, if any.
+ '''
+ primary = m.macaroon
+ discharges = [primary]
+
+ # cav holds the macaroon caveat that needs discharge.
+ # encrypted_caveat (bytes) holds encrypted caveat if it was held
+ # externally.
+ _NeedCaveat = namedtuple('_NeedCaveat', 'cav encrypted_caveat')
+ need = []
+
+ def add_caveats(m):
+ for cav in m.macaroon.caveats:
+ if cav.location is None or cav.location == '':
+ continue
+ encrypted_caveat = m.caveat_data.get(cav.caveat_id, None)
+ need.append(
+ _NeedCaveat(cav=cav,
+ encrypted_caveat=encrypted_caveat))
+ add_caveats(m)
+ while len(need) > 0:
+ cav = need[0]
+ need = need[1:]
+ if local_key is not None and cav.cav.location == 'local':
+ # TODO use a small caveat id.
+ dm = discharge(ctx=ctx, key=local_key,
+ checker=_LocalDischargeChecker(),
+ caveat=cav.encrypted_caveat,
+ id=cav.cav.caveat_id_bytes,
+ locator=_EmptyLocator())
+ else:
+ dm = get_discharge(ctx, cav.cav, cav.encrypted_caveat)
+ # It doesn't matter that we're invalidating dm here because we're
+ # about to throw it away.
+ discharge_m = dm.macaroon
+ m = primary.prepare_for_request(discharge_m)
+ discharges.append(m)
+ add_caveats(dm)
+ return discharges
+
+
+class ThirdPartyCaveatChecker(object):
+ ''' Defines an abstract class that's used to check third party caveats.
+ '''
+ __metaclass__ = abc.ABCMeta
+
+ @abc.abstractmethod
+ def check_third_party_caveat(self, ctx, info):
+ ''' If the caveat is valid, it returns optionally a slice of
+ extra caveats that will be added to the discharge macaroon.
+ If the caveat kind was not recognised, the checker should
+ raise a CaveatNotRecognized exception; if the check failed,
+ it should raise a ThirdPartyCaveatCheckFailed exception.
+ :param ctx (AuthContext)
+ :param info (ThirdPartyCaveatInfo) holds the information decoded from
+ a third party caveat id
+ :return: An array of extra caveats to be added to the discharge
+ macaroon.
+ '''
+ raise NotImplementedError('check_third_party_caveat method must be '
+ 'defined in subclass')
+
+
+class _LocalDischargeChecker(ThirdPartyCaveatChecker):
+ def check_third_party_caveat(self, ctx, info):
+ if info.condition != 'true':
+ raise macaroonbakery.CaveatNotRecognizedError()
+ return []
+
+
+def discharge(ctx, id, caveat, key, checker, locator):
+ ''' Creates a macaroon to discharge a third party caveat.
+
+ The given parameters specify the caveat and how it should be checked.
+ The condition implicit in the caveat is checked for validity using checker.
+ If it is valid, a new macaroon is returned which discharges the caveat.
+ The macaroon is created with a version derived from the version that was
+ used to encode the id.
+
+ :param id: (bytes) holds the id to give to the discharge macaroon.
+ If Caveat is empty, then the id also holds the encrypted third party
+ caveat.
+ :param caveat: (bytes) holds the encrypted third party caveat.
+ If this is None, id will be used.
+ :param key: holds the key to use to decrypt the third party caveat
+ information and to encrypt any additional third party caveats returned by
+ the caveat checker.
+ :param checker: used to check the third party caveat, and may also return
+ further caveats to be added to the discharge macaroon.
+ :param locator: used to information on third parties referred to by third
+ party caveats returned by the Checker.
+ '''
+ caveat_id_prefix = []
+ if caveat is None:
+ # The caveat information is encoded in the id itself.
+ caveat = id
+ else:
+ # We've been given an explicit id, so when extra third party
+ # caveats are added, use that id as the prefix
+ # for any more ids.
+ caveat_id_prefix = id
+ cav_info = macaroonbakery.decode_caveat(key, caveat)
+
+ # Note that we don't check the error - we allow the
+ # third party checker to see even caveats that we can't
+ # understand.
+ try:
+ cond, arg = checkers.parse_caveat(cav_info.condition)
+ except ValueError as exc:
+ raise macaroonbakery.VerificationError(exc.args[0])
+
+ if cond == checkers.COND_NEED_DECLARED:
+ cav_info = cav_info._replace(condition=arg.encode('utf-8'))
+ caveats = _check_need_declared(ctx, cav_info, checker)
+ else:
+ caveats = checker.check_third_party_caveat(ctx, cav_info)
+
+ # Note that the discharge macaroon does not need to
+ # be stored persistently. Indeed, it would be a problem if
+ # we did, because then the macaroon could potentially be used
+ # for normal authorization with the third party.
+ m = macaroonbakery.Macaroon(cav_info.root_key, id, '', cav_info.version,
+ cav_info.namespace)
+ m._caveat_id_prefix = caveat_id_prefix
+ if caveats is not None:
+ for cav in caveats:
+ m.add_caveat(cav, key, locator)
+ return m
+
+
+def _check_need_declared(ctx, cav_info, checker):
+ arg = cav_info.condition.decode('utf-8')
+ i = arg.find(' ')
+ if i <= 0:
+ raise macaroonbakery.VerificationError(
+ 'need-declared caveat requires an argument, got %q'.format(arg))
+ need_declared = arg[0:i].split(',')
+ for d in need_declared:
+ if d == '':
+ raise macaroonbakery.VerificationError('need-declared caveat with '
+ 'empty required attribute')
+ if len(need_declared) == 0:
+ raise macaroonbakery.VerificationError('need-declared caveat with no '
+ 'required attributes')
+ cav_info = cav_info._replace(condition=arg[i + 1:].encode('utf-8'))
+ caveats = checker.check_third_party_caveat(ctx, cav_info)
+ declared = {}
+ for cav in caveats:
+ if cav.location is not None and cav.location != '':
+ continue
+ # Note that we ignore the error. We allow the service to
+ # generate caveats that we don't understand here.
+ try:
+ cond, arg = checkers.parse_caveat(cav.condition)
+ except ValueError:
+ continue
+ if cond != checkers.COND_DECLARED:
+ continue
+ parts = arg.split()
+ if len(parts) != 2:
+ raise macaroonbakery.VerificationError('declared caveat has no '
+ 'value')
+ declared[parts[0]] = True
+ # Add empty declarations for everything mentioned in need-declared
+ # that was not actually declared.
+ for d in need_declared:
+ if not declared.get(d, False):
+ caveats.append(checkers.declared_caveat(d, ''))
+ return caveats
+
+
+class _EmptyLocator(macaroonbakery.ThirdPartyLocator):
+ def third_party_info(self, loc):
+ return None
+
+
+def local_third_party_caveat(key, version):
+ ''' Returns a third-party caveat that, when added to a macaroon with
+ add_caveat, results in a caveat with the location "local", encrypted with
+ the given PublicKey.
+ This can be automatically discharged by discharge_all passing a local key.
+ '''
+ encoded_key = key.encode().decode('utf-8')
+ loc = 'local {}'.format(encoded_key)
+ if version >= macaroonbakery.BAKERY_V2:
+ loc = 'local {} {}'.format(version, encoded_key)
+ return checkers.Caveat(location=loc, condition='')
diff --git a/macaroonbakery/error.py b/macaroonbakery/error.py
new file mode 100644
index 0000000..b403569
--- /dev/null
+++ b/macaroonbakery/error.py
@@ -0,0 +1,77 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+
+
+class DischargeRequiredError(Exception):
+ ''' Raised by checker when authorization has failed and a discharged
+ macaroon might fix it.
+
+ A caller should grant the user the ability to authorize by minting a
+ macaroon associated with Ops (see MacaroonStore.MacaroonIdInfo for
+ how the associated operations are retrieved) and adding Caveats. If
+ the user succeeds in discharging the caveats, the authorization will
+ be granted.
+ '''
+ def __init__(self, msg, ops, cavs):
+ '''
+ :param msg: holds some reason why the authorization was denied.
+ :param ops: holds all the operations that were not authorized.
+ If ops contains a single LOGIN_OP member, the macaroon
+ should be treated as an login token. Login tokens (also
+ known as authentication macaroons) usually have a longer
+ life span than other macaroons.
+ :param cavs: holds the caveats that must be added to macaroons that
+ authorize the above operations.
+ '''
+ super(DischargeRequiredError, self).__init__(msg)
+ self._ops = ops
+ self._cavs = cavs
+
+ def ops(self):
+ return self._ops
+
+ def cavs(self):
+ return self._cavs
+
+
+class PermissionDenied(Exception):
+ '''Raised from AuthChecker when permission has been denied.
+ '''
+ pass
+
+
+class CaveatNotRecognizedError(Exception):
+ '''Containing the cause of errors returned from caveat checkers when the
+ caveat was not recognized.
+ '''
+ pass
+
+
+class VerificationError(Exception):
+ '''Raised to signify that an error is because of a verification failure
+ rather than because verification could not be done.'''
+ pass
+
+
+class AuthInitError(Exception):
+ '''Raised if AuthChecker cannot be initialized properly.'''
+ pass
+
+
+class IdentityError(Exception):
+ ''' Raised from IdentityClient.declared_identity when an error occurs.
+ '''
+ pass
+
+
+class ThirdPartyCaveatCheckFailed(Exception):
+ ''' Raised from ThirdPartyCaveatChecker.check_third_party when check fails.
+ '''
+ pass
+
+
+class ThirdPartyInfoNotFound(Exception):
+ ''' Raised from implementation of ThirdPartyLocator.third_party_info when
+ the info cannot be found.
+ '''
+ pass
diff --git a/macaroonbakery/httpbakery/__init__.py b/macaroonbakery/httpbakery/__init__.py
index 4ebcf23..3b40dc2 100644
--- a/macaroonbakery/httpbakery/__init__.py
+++ b/macaroonbakery/httpbakery/__init__.py
@@ -1 +1,17 @@
-from .client import BakeryAuth # NOQA
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+from macaroonbakery.httpbakery.client import BakeryAuth, extract_macaroons
+from macaroonbakery.httpbakery.error import (
+ BAKERY_PROTOCOL_HEADER, discharged_required_response, request_version
+)
+from macaroonbakery.httpbakery.keyring import ThirdPartyLocator
+
+
+__all__ = [
+ 'BAKERY_PROTOCOL_HEADER',
+ 'BakeryAuth',
+ 'ThirdPartyLocator',
+ 'discharged_required_response',
+ 'extract_macaroons',
+ 'request_version',
+]
diff --git a/macaroonbakery/httpbakery/agent.py b/macaroonbakery/httpbakery/agent.py
index 3676bae..e5a09e4 100644
--- a/macaroonbakery/httpbakery/agent.py
+++ b/macaroonbakery/httpbakery/agent.py
@@ -1,6 +1,5 @@
# Copyright 2017 Canonical Ltd.
# Licensed under the LGPLv3, see LICENCE file for details.
-
import base64
import json
@@ -12,20 +11,20 @@ from six.moves.urllib.parse import urlparse
class AgentFileFormatError(Exception):
- """ AgentFileFormatError is the exception raised when an agent file has a bad
+ ''' 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.
+ ''' 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)
@@ -50,4 +49,4 @@ def load_agent_file(filename, cookies=None):
cookies.set_cookie(cookie)
return cookies, key
except (KeyError, ValueError) as e:
- raise AgentFileFormatError("invalid agent file", e)
+ raise AgentFileFormatError('invalid agent file', e)
diff --git a/macaroonbakery/httpbakery/client.py b/macaroonbakery/httpbakery/client.py
index 32f35dd..b62c61d 100644
--- a/macaroonbakery/httpbakery/client.py
+++ b/macaroonbakery/httpbakery/client.py
@@ -1,12 +1,16 @@
# Copyright 2017 Canonical Ltd.
# Licensed under the LGPLv3, see LICENCE file for details.
-
import base64
+import json
import requests
+from six.moves.http_cookies import SimpleCookie
from six.moves.http_cookiejar import Cookie
from six.moves.urllib.parse import urljoin
from six.moves.urllib.parse import urlparse
+from pymacaroons import Macaroon
+from pymacaroons.serializers.json_serializer import JsonSerializer
+
from macaroonbakery.bakery import discharge_all
from macaroonbakery import utils
@@ -155,3 +159,23 @@ def _visit_page_for_agent(cookies, key):
auth=BakeryAuth(cookies=cookies, key=key))
resp.raise_for_status()
return visit_page_for_agent
+
+
+def extract_macaroons(headers):
+ ''' Returns an array of any macaroons found in the given slice of cookies.
+ @param headers: dict of headers
+ @return: An array of array of mpy macaroons
+ '''
+ cookie_string = "\n".join(headers.get_all('Cookie', failobj=[]))
+ cs = SimpleCookie()
+ cs.load(cookie_string)
+ mss = []
+ for c in cs:
+ if not c.startswith('macaroon-'):
+ continue
+ data = base64.b64decode(cs[c].value)
+ data_as_objs = json.loads(data.decode('utf-8'))
+ ms = [Macaroon.deserialize(json.dumps(x), serializer=JsonSerializer())
+ for x in data_as_objs]
+ mss.append(ms)
+ return mss
diff --git a/macaroonbakery/httpbakery/error.py b/macaroonbakery/httpbakery/error.py
new file mode 100644
index 0000000..e138c66
--- /dev/null
+++ b/macaroonbakery/httpbakery/error.py
@@ -0,0 +1,67 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+import json
+
+import macaroonbakery
+
+
+def discharged_required_response(macaroon, path, cookie_suffix_name):
+ ''' Get response content and headers from a discharge macaroons error.
+
+ @param macaroon may hold a macaroon that, when discharged, may
+ allow access to a service.
+ @param path holds the URL path to be associated with the macaroon.
+ The macaroon is potentially valid for all URLs under the given path.
+ @param cookie_suffix_name holds the desired cookie name suffix to be
+ associated with the macaroon. The actual name used will be
+ ("macaroon-" + CookieName). Clients may ignore this field -
+ older clients will always use ("macaroon-" + macaroon.signature() in hex)
+ @return content(bytes) and the headers to set on the response(dict).
+ '''
+ content = json.dumps(
+ {
+ 'Code': 'macaroon discharge required',
+ 'Message': 'discharge required',
+ 'Info': {
+ 'Macaroon': macaroon.to_dict(),
+ 'MacaroonPath': path,
+ 'CookieNameSuffix': cookie_suffix_name
+ },
+ }
+ )
+ return content, {
+ 'WWW-Authenticate': 'Macaroon',
+ 'Content-Type': 'application/json'
+ }
+
+# BAKERY_PROTOCOL_HEADER is the header that HTTP clients should set
+# to determine the bakery protocol version. If it is 0 or missing,
+# a discharge-required error response will be returned with HTTP status 407;
+# if it is greater than 0, the response will have status 401 with the
+# WWW-Authenticate header set to "Macaroon".
+BAKERY_PROTOCOL_HEADER = 'Bakery-Protocol-Version'
+
+
+def request_version(req_headers):
+ ''' Determines the bakery protocol version from a client request.
+ If the protocol cannot be determined, or is invalid, the original version
+ of the protocol is used. If a later version is found, the latest known
+ version is used, which is OK because versions are backwardly compatible.
+
+ @param req_headers: the request headers as a dict.
+ @return: bakery protocol version (for example macaroonbakery.BAKERY_V1)
+ '''
+ vs = req_headers.get(BAKERY_PROTOCOL_HEADER)
+ if vs is None:
+ # No header - use backward compatibility mode.
+ return macaroonbakery.BAKERY_V1
+ try:
+ x = int(vs)
+ except ValueError:
+ # Badly formed header - use backward compatibility mode.
+ return macaroonbakery.BAKERY_V1
+ if x > macaroonbakery.LATEST_BAKERY_VERSION:
+ # Later version than we know about - use the
+ # latest version that we can.
+ return macaroonbakery.LATEST_BAKERY_VERSION
+ return x
diff --git a/macaroonbakery/httpbakery/keyring.py b/macaroonbakery/httpbakery/keyring.py
new file mode 100644
index 0000000..f4e93f7
--- /dev/null
+++ b/macaroonbakery/httpbakery/keyring.py
@@ -0,0 +1,56 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+from six.moves.urllib.parse import urlparse
+import requests
+
+import macaroonbakery
+
+
+class ThirdPartyLocator(macaroonbakery.ThirdPartyLocator):
+ ''' Implements macaroonbakery.ThirdPartyLocator by first looking in the
+ backing cache and, if that fails, making an HTTP request to find the
+ information associated with the given discharge location.
+ '''
+
+ def __init__(self, allow_insecure=False):
+ '''
+ @param url: the url to retrieve public_key
+ @param allow_insecure: By default it refuses to use insecure URLs.
+ '''
+ self._allow_insecure = allow_insecure
+ self._cache = {}
+
+ def third_party_info(self, loc):
+ u = urlparse(loc)
+ if u.scheme != 'https' and not self._allow_insecure:
+ raise macaroonbakery.ThirdPartyInfoNotFound(
+ 'untrusted discharge URL {}'.format(loc))
+ loc = loc.rstrip('/')
+ info = self._cache.get(loc)
+ if info is not None:
+ return info
+ url_endpoint = '/discharge/info'
+ resp = requests.get(loc + url_endpoint)
+ status_code = resp.status_code
+ if status_code == 404:
+ url_endpoint = '/publickey'
+ resp = requests.get(loc + url_endpoint)
+ status_code = resp.status_code
+ if status_code != 200:
+ raise macaroonbakery.ThirdPartyInfoNotFound(
+ 'unable to get info from {}'.format(url_endpoint))
+ json_resp = resp.json()
+ if json_resp is None:
+ raise macaroonbakery.ThirdPartyInfoNotFound(
+ 'no response from /discharge/info')
+ pk = json_resp.get('PublicKey')
+ if pk is None:
+ raise macaroonbakery.ThirdPartyInfoNotFound(
+ 'no public key found in /discharge/info')
+ idm_pk = macaroonbakery.PublicKey.deserialize(pk)
+ version = json_resp.get('Version', macaroonbakery.BAKERY_V1)
+ self._cache[loc] = macaroonbakery.ThirdPartyInfo(
+ version=version,
+ public_key=idm_pk
+ )
+ return self._cache.get(loc)
diff --git a/macaroonbakery/identity.py b/macaroonbakery/identity.py
new file mode 100644
index 0000000..23e2e4b
--- /dev/null
+++ b/macaroonbakery/identity.py
@@ -0,0 +1,126 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+import abc
+
+import macaroonbakery
+
+
+class Identity(object):
+ ''' Holds identity information declared in a first party caveat added when
+ discharging a third party caveat.
+ '''
+ __metaclass__ = abc.ABCMeta
+
+ @abc.abstractmethod
+ def id(self):
+ ''' Returns the id of the user.
+
+ May be an opaque blob with no human meaning. An id is only considered
+ to be unique with a given domain.
+ :return string
+ '''
+ raise NotImplementedError('id method must be defined in subclass')
+
+ @abc.abstractmethod
+ def domain(self):
+ '''Return the domain of the user.
+
+ This will be empty if the user was authenticated
+ directly with the identity provider.
+ :return string
+ '''
+ raise NotImplementedError('domain method must be defined in subclass')
+
+
+class ACLIdentity(Identity):
+ ''' ACLIdentity may be implemented by Identity implementations
+ to report group membership information.
+ '''
+ __metaclass__ = abc.ABCMeta
+
+ @abc.abstractmethod
+ def allow(self, ctx, acls):
+ ''' reports whether the user should be allowed to access
+ any of the users or groups in the given acl list.
+ :param ctx(AuthContext) is the context of the authorization request.
+ :param acls array of string acl
+ :return boolean
+ '''
+ raise NotImplementedError('allow method must be defined in subclass')
+
+
+class SimpleIdentity(ACLIdentity):
+ ''' A simple form of identity where the user is represented by a string.
+ '''
+ def __init__(self, user):
+ self._identity = user
+
+ def domain(self):
+ ''' A simple identity has no domain.
+ '''
+ return ''
+
+ def id(self):
+ '''Return the user name as the id.
+ '''
+ return self._identity
+
+ def allow(self, ctx, acls):
+ '''Allow access to any ACL members that was equal to the user name.
+
+ That is, some user u is considered a member of group u and no other.
+ '''
+ for acl in acls:
+ if self._identity == acl:
+ return True
+ return False
+
+
+class IdentityClient(object):
+ ''' Represents an abstract identity manager. User identities can be based
+ on local informaton (for example HTTP basic auth) or by reference to an
+ external trusted third party (an identity manager).
+ '''
+ __metaclass__ = abc.ABCMeta
+
+ @abc.abstractmethod
+ def identity_from_context(self, ctx):
+ ''' Returns the identity based on information in the context.
+
+ If it cannot determine the identity based on the context, then it
+ should return a set of caveats containing a third party caveat that,
+ when discharged, can be used to obtain the identity with
+ declared_identity.
+
+ It should only raise an error if it cannot check the identity
+ (for example because of a database access error) - it's
+ OK to return all zero values when there's
+ no identity found and no third party to address caveats to.
+ :param: ctx an AuthContext
+ :return: an Identity and array of caveats
+ '''
+ raise NotImplementedError('identity_from_context method must be '
+ 'defined in subclass')
+
+ @abc.abstractmethod
+ def declared_identity(self, ctx, declared):
+ '''Parses the identity declaration from the given declared attributes.
+
+ TODO take the set of first party caveat conditions instead?
+ :param: ctx (AuthContext)
+ :param: declared (dict of string/string)
+ :return: an Identity
+ '''
+ raise NotImplementedError('declared_identity method must be '
+ 'defined in subclass')
+
+
+class NoIdentities(IdentityClient):
+ ''' Defines the null identity provider - it never returns any identities.
+ '''
+
+ def identity_from_context(self, ctx):
+ return None, None
+
+ def declared_identity(self, ctx, declared):
+ raise macaroonbakery.IdentityError('no identity declared or possible')
diff --git a/macaroonbakery/internal/__init__.py b/macaroonbakery/internal/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/macaroonbakery/internal/__init__.py
diff --git a/macaroonbakery/internal/id.proto b/macaroonbakery/internal/id.proto
new file mode 100644
index 0000000..eb3d614
--- /dev/null
+++ b/macaroonbakery/internal/id.proto
@@ -0,0 +1,14 @@
+syntax="proto3";
+
+option go_package = "macaroonpb";
+
+message MacaroonId {
+ bytes nonce = 1;
+ bytes storageId = 2;
+ repeated Op ops = 3;
+}
+
+message Op {
+ string entity = 1;
+ repeated string actions = 2;
+}
diff --git a/macaroonbakery/internal/id_pb2.py b/macaroonbakery/internal/id_pb2.py
new file mode 100644
index 0000000..0fd54c0
--- /dev/null
+++ b/macaroonbakery/internal/id_pb2.py
@@ -0,0 +1,132 @@
+# Generated by the protocol buffer compiler. DO NOT EDIT!
+# source: macaroonbakery/internal/id.proto
+
+import sys
+_b=sys.version_info[0]<3 and (lambda x:x) or (lambda x:x.encode('latin1'))
+from google.protobuf import descriptor as _descriptor
+from google.protobuf import message as _message
+from google.protobuf import reflection as _reflection
+from google.protobuf import symbol_database as _symbol_database
+from google.protobuf import descriptor_pb2
+# @@protoc_insertion_point(imports)
+
+_sym_db = _symbol_database.Default()
+
+
+
+
+DESCRIPTOR = _descriptor.FileDescriptor(
+ name='macaroonbakery/internal/id.proto',
+ package='',
+ syntax='proto3',
+ serialized_pb=_b('\n macaroonbakery/internal/id.proto\"@\n\nMacaroonId\x12\r\n\x05nonce\x18\x01 \x01(\x0c\x12\x11\n\tstorageId\x18\x02 \x01(\x0c\x12\x10\n\x03ops\x18\x03 \x03(\x0b\x32\x03.Op\"%\n\x02Op\x12\x0e\n\x06\x65ntity\x18\x01 \x01(\t\x12\x0f\n\x07\x61\x63tions\x18\x02 \x03(\tB\x0cZ\nmacaroonpbb\x06proto3')
+)
+
+
+
+
+_MACAROONID = _descriptor.Descriptor(
+ name='MacaroonId',
+ full_name='MacaroonId',
+ filename=None,
+ file=DESCRIPTOR,
+ containing_type=None,
+ fields=[
+ _descriptor.FieldDescriptor(
+ name='nonce', full_name='MacaroonId.nonce', index=0,
+ number=1, type=12, cpp_type=9, label=1,
+ has_default_value=False, default_value=_b(""),
+ message_type=None, enum_type=None, containing_type=None,
+ is_extension=False, extension_scope=None,
+ options=None),
+ _descriptor.FieldDescriptor(
+ name='storageId', full_name='MacaroonId.storageId', index=1,
+ number=2, type=12, cpp_type=9, label=1,
+ has_default_value=False, default_value=_b(""),
+ message_type=None, enum_type=None, containing_type=None,
+ is_extension=False, extension_scope=None,
+ options=None),
+ _descriptor.FieldDescriptor(
+ name='ops', full_name='MacaroonId.ops', index=2,
+ number=3, type=11, cpp_type=10, label=3,
+ has_default_value=False, default_value=[],
+ message_type=None, enum_type=None, containing_type=None,
+ is_extension=False, extension_scope=None,
+ options=None),
+ ],
+ extensions=[
+ ],
+ nested_types=[],
+ enum_types=[
+ ],
+ options=None,
+ is_extendable=False,
+ syntax='proto3',
+ extension_ranges=[],
+ oneofs=[
+ ],
+ serialized_start=36,
+ serialized_end=100,
+)
+
+
+_OP = _descriptor.Descriptor(
+ name='Op',
+ full_name='Op',
+ filename=None,
+ file=DESCRIPTOR,
+ containing_type=None,
+ fields=[
+ _descriptor.FieldDescriptor(
+ name='entity', full_name='Op.entity', index=0,
+ number=1, type=9, cpp_type=9, label=1,
+ has_default_value=False, default_value=_b("").decode('utf-8'),
+ message_type=None, enum_type=None, containing_type=None,
+ is_extension=False, extension_scope=None,
+ options=None),
+ _descriptor.FieldDescriptor(
+ name='actions', full_name='Op.actions', index=1,
+ number=2, type=9, cpp_type=9, label=3,
+ has_default_value=False, default_value=[],
+ message_type=None, enum_type=None, containing_type=None,
+ is_extension=False, extension_scope=None,
+ options=None),
+ ],
+ extensions=[
+ ],
+ nested_types=[],
+ enum_types=[
+ ],
+ options=None,
+ is_extendable=False,
+ syntax='proto3',
+ extension_ranges=[],
+ oneofs=[
+ ],
+ serialized_start=102,
+ serialized_end=139,
+)
+
+_MACAROONID.fields_by_name['ops'].message_type = _OP
+DESCRIPTOR.message_types_by_name['MacaroonId'] = _MACAROONID
+DESCRIPTOR.message_types_by_name['Op'] = _OP
+_sym_db.RegisterFileDescriptor(DESCRIPTOR)
+
+MacaroonId = _reflection.GeneratedProtocolMessageType('MacaroonId', (_message.Message,), dict(
+ DESCRIPTOR = _MACAROONID,
+ __module__ = 'macaroonbakery.internal.id_pb2'
+ # @@protoc_insertion_point(class_scope:MacaroonId)
+ ))
+_sym_db.RegisterMessage(MacaroonId)
+
+Op = _reflection.GeneratedProtocolMessageType('Op', (_message.Message,), dict(
+ DESCRIPTOR = _OP,
+ __module__ = 'macaroonbakery.internal.id_pb2'
+ # @@protoc_insertion_point(class_scope:Op)
+ ))
+_sym_db.RegisterMessage(Op)
+
+
+DESCRIPTOR.has_options = True
+DESCRIPTOR._options = _descriptor._ParseOptions(descriptor_pb2.FileOptions(), _b('Z\nmacaroonpb'))
+# @@protoc_insertion_point(module_scope)
diff --git a/macaroonbakery/json_serializer.py b/macaroonbakery/json_serializer.py
deleted file mode 100644
index 2faea00..0000000
--- a/macaroonbakery/json_serializer.py
+++ /dev/null
@@ -1,75 +0,0 @@
-# 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/keys.py b/macaroonbakery/keys.py
new file mode 100644
index 0000000..5cf61c5
--- /dev/null
+++ b/macaroonbakery/keys.py
@@ -0,0 +1,92 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+
+import nacl.public
+
+
+class PrivateKey(object):
+ ''' A private key used by the bakery to encrypt and decrypt
+ third party caveats.
+ Internally, it is a 256-bit Ed25519 private key.
+ '''
+ def __init__(self, key):
+ self._key = key
+
+ @property
+ def key(self):
+ ''' Internal nacl key representation.
+ '''
+ return self._key
+
+ @property
+ def public_key(self):
+ '''
+ :return: the PublicKey associated with the private key.
+ '''
+ return PublicKey(self._key.public_key)
+
+ @classmethod
+ def deserialize(cls, serialized):
+ ''' Create a PrivateKey from a base64 encoded bytes.
+ :return: a PrivateKey
+ '''
+ return PrivateKey(
+ nacl.public.PrivateKey(serialized,
+ encoder=nacl.encoding.Base64Encoder))
+
+ def encode(self, raw=False):
+ ''' Encode the key in a base64 format by default but when raw is True
+ it will return a an hex encoded bytes.
+ @return: bytes
+ '''
+ if raw:
+ return self._key.encode()
+ return self._key.encode(nacl.encoding.Base64Encoder)
+
+ def __eq__(self, other):
+ return self.key == other.key
+
+
+class PublicKey(object):
+ ''' A public key used by the bakery to encrypt third party caveats.
+
+ Every discharger is associated with a public key which is used to
+ encrypt third party caveat ids addressed to that discharger.
+ Internally, it is a 256 bit Ed25519 public key.
+ '''
+ def __init__(self, key):
+ self._key = key
+
+ @property
+ def key(self):
+ ''' Internal nacl key representation.
+ '''
+ return self._key
+
+ def encode(self, raw=False):
+ ''' Encode the key in a base64 format by default but when raw is True
+ it will return a an hex encoded bytes.
+ @return: bytes
+ '''
+ if raw:
+ return self._key.encode()
+ return self._key.encode(nacl.encoding.Base64Encoder)
+
+ @classmethod
+ def deserialize(cls, serialized):
+ ''' Create a PublicKey from a base64 encoded bytes.
+ :return: a PublicKey
+ '''
+ return PublicKey(
+ nacl.public.PublicKey(serialized,
+ encoder=nacl.encoding.Base64Encoder))
+
+ def __eq__(self, other):
+ return self.key == other.key
+
+
+def generate_key():
+ '''GenerateKey generates a new PrivateKey.
+ :return: a PrivateKey
+ '''
+ return PrivateKey(nacl.public.PrivateKey.generate())
diff --git a/macaroonbakery/macaroon.py b/macaroonbakery/macaroon.py
index 954161c..6f6039e 100644
--- a/macaroonbakery/macaroon.py
+++ b/macaroonbakery/macaroon.py
@@ -1,37 +1,30 @@
# Copyright 2017 Canonical Ltd.
# Licensed under the LGPLv3, see LICENCE file for details.
-
+import abc
import base64
-import copy
+import json
import logging
import os
-from macaroonbakery import bakery
-from macaroonbakery import codec
import pymacaroons
+from pymacaroons.serializers import json_serializer
-from macaroonbakery import namespace
+import macaroonbakery
+import macaroonbakery.checkers as checkers
+from macaroonbakery import utils
-MACAROON_V1, MACAROON_V2 = 1, 2
log = logging.getLogger(__name__)
-def legacy_namespace():
- ''' Standard namespace for pre-version3 macaroons.
- '''
- ns = namespace.Namespace(None)
- ns.register(namespace.STD_NAMESPACE, '')
- return ns
-
-
-class Macaroon:
- '''Represent an undischarged macaroon along its first
+class Macaroon(object):
+ '''Represent an undischarged macaroon along with its first
party caveat namespace and associated third party caveat information
which should be passed to the third party when discharging a caveat.
'''
+
def __init__(self, root_key, id, location=None,
- version=bakery.LATEST_BAKERY_VERSION, ns=None):
+ version=macaroonbakery.LATEST_BAKERY_VERSION, namespace=None):
'''Creates a new macaroon with the given root key, id and location.
If the version is more than the latest known version,
@@ -41,22 +34,45 @@ class Macaroon:
@param id bytes or string
@param location bytes or string
@param version the bakery version.
- @param ns
+ @param namespace is that of the service creating it
'''
- if version > bakery.LATEST_BAKERY_VERSION:
+ if version > macaroonbakery.LATEST_BAKERY_VERSION:
log.info('use last known version:{} instead of: {}'.format(
- bakery.LATEST_BAKERY_VERSION, version
+ macaroonbakery.LATEST_BAKERY_VERSION, version
))
- version = bakery.LATEST_BAKERY_VERSION
+ version = macaroonbakery.LATEST_BAKERY_VERSION
# m holds the underlying macaroon.
- self._macaroon = pymacaroons.Macaroon(location=location, key=root_key,
- identifier=id)
+ self._macaroon = pymacaroons.Macaroon(
+ location=location, key=root_key, identifier=id,
+ version=macaroon_version(version))
# version holds the version of the macaroon.
- self.version = macaroon_version(version)
- self.caveat_data = {}
+ self._version = version
+ self._caveat_data = {}
+ if namespace is None:
+ namespace = checkers.Namespace()
+ self._namespace = namespace
+ self._caveat_id_prefix = bytearray()
+
+ @property
+ def macaroon(self):
+ ''' Return the underlying macaroon.
+ '''
+ return self._macaroon
+
+ @property
+ def version(self):
+ return self._version
+
+ @property
+ def namespace(self):
+ return self._namespace
+
+ @property
+ def caveat_data(self):
+ return self._caveat_data
def add_caveat(self, cav, key=None, loc=None):
- '''Return a new macaroon with the given caveat added.
+ '''Add a caveat to the macaroon.
It encrypts it using the given key pair
and by looking up the location using the given locator.
@@ -68,96 +84,138 @@ class Macaroon:
key.
@param cav the checkers.Caveat to be added.
- @param key the nacl public key to encrypt third party caveat.
+ @param key the public key to encrypt third party caveat.
@param loc locator to find information on third parties when adding
third party caveats. It is expected to have a third_party_info method
that will be called with a location string and should return a
ThirdPartyInfo instance holding the requested information.
- @return a new macaroon object with the given caveat.
'''
if cav.location is None:
- macaroon = self._macaroon.add_first_party_caveat(cav.condition)
- new_macaroon = copy.copy(self)
- new_macaroon._macaroon = macaroon
- return new_macaroon
+ self._macaroon.add_first_party_caveat(
+ self.namespace.resolve_caveat(cav).condition)
+ return
if key is None:
raise ValueError(
'no private key to encrypt third party caveat')
- local_info, ok = parse_local_location(cav.location)
- if ok:
+ local_info = _parse_local_location(cav.location)
+ if local_info is not None:
info = local_info
- cav.location = 'local'
if cav.condition is not '':
raise ValueError(
'cannot specify caveat condition in '
'local third-party caveat')
- cav.condition = 'true'
+ cav = checkers.Caveat(location='local', condition='true')
else:
if loc is None:
raise ValueError(
'no locator when adding third party caveat')
info = loc.third_party_info(cav.location)
+
root_key = os.urandom(24)
+
# Use the least supported version to encode the caveat.
- if self.version < info.version:
- info.version = self.version
+ if self._version < info.version:
+ info = macaroonbakery.ThirdPartyInfo(version=self._version,
+ public_key=info.public_key)
- caveat_info = codec.encode_caveat(cav.condition, root_key, info,
- key, None)
- if info.version < bakery.BAKERY_V3:
+ caveat_info = macaroonbakery.encode_caveat(
+ cav.condition, root_key, info, key, self._namespace)
+ if info.version < macaroonbakery.BAKERY_V3:
# We're encoding for an earlier client or third party which does
# not understand bundled caveat info, so use the encoded
# caveat information as the caveat id.
id = caveat_info
else:
- id = self._new_caveat_id(self.caveat_id_prefix)
- self.caveat_data[id] = caveat_info
+ id = self._new_caveat_id(self._caveat_id_prefix)
+ self._caveat_data[id] = caveat_info
- m = self._macaroon.add_third_party_caveat(cav.location, root_key, id)
- new_macaroon = copy.copy(self)
- new_macaroon._macaroon = m
- return new_macaroon
+ self._macaroon.add_third_party_caveat(cav.location, root_key, id)
def add_caveats(self, cavs, key, loc):
- '''Return a new macaroon with all caveats added.
+ '''Add an array of caveats to the macaroon.
This method does not mutate the current object.
@param cavs arrary of caveats.
- @param key the nacl public key to encrypt third party caveat.
+ @param key the PublicKey to encrypt third party caveat.
@param loc locator to find the location object that has a method
third_party_info.
- @return a new macaroon object with the given caveats.
'''
- macaroon = self
+ if cavs is None:
+ return
for cav in cavs:
- macaroon = macaroon.add_caveat(cav, key, loc)
- return macaroon
+ self.add_caveat(cav, key, loc)
- def serialize(self):
- '''Return a dictionary holding the macaroon data in V1 JSON format.
-
- Note that this differs from the underlying macaroon serialize method as
- it does not return a string. This makes it easier to incorporate the
- macaroon into other JSON objects.
+ def serialize_json(self):
+ '''Return a string holding the macaroon data in JSON format.
+ @return a string holding the macaroon data in JSON format
+ '''
+ return json.dumps(self.to_dict())
- @return a dictionary holding the macaroon data
- in V1 JSON format
+ def to_dict(self):
+ '''Return a dict representation of the macaroon data in JSON format.
+ @return a dict
'''
- if self.version == bakery.BAKERY_V1:
- # latest libmacaroons do not support the old format
- json_macaroon = self._macaroon.serialize('json')
- val = {
- 'identifier': _field_v2(json_macaroon, 'i'),
- 'signature': _field_v2(json_macaroon, 's'),
- }
- location = json_macaroon.get('l')
- if location is not None:
- val['location'] = location
- cavs = json_macaroon.get('c')
- if cavs is not None:
- val['caveats'] = map(cavs, _cav_v2_to_v1)
- return val
- raise NotImplementedError('only bakery v1 supported')
+ if self.version < macaroonbakery.BAKERY_V3:
+ if len(self._caveat_data) > 0:
+ raise ValueError('cannot serialize pre-version3 macaroon with '
+ 'external caveat data')
+ return json.loads(self._macaroon.serialize(
+ json_serializer.JsonSerializer()))
+ serialized = {
+ 'm': json.loads(self._macaroon.serialize(
+ json_serializer.JsonSerializer())),
+ 'v': self._version,
+ }
+ if self._namespace is not None:
+ serialized['ns'] = self._namespace.serialize_text().decode('utf-8')
+ caveat_data = {}
+ for id in self._caveat_data:
+ key = base64.b64encode(id).decode('utf-8')
+ value = base64.b64encode(self._caveat_data[id]).decode('utf-8')
+ caveat_data[key] = value
+ if len(caveat_data) > 0:
+ serialized['cdata'] = caveat_data
+ return serialized
+
+ @classmethod
+ def deserialize_json(cls, serialized_json):
+ serialized = json.loads(serialized_json)
+ json_macaroon = serialized.get('m')
+ if json_macaroon is None:
+ # Try the v1 format if we don't have a macaroon filed
+ m = pymacaroons.Macaroon.deserialize(
+ serialized_json, json_serializer.JsonSerializer())
+ macaroon = Macaroon(root_key=None, id=None,
+ namespace=macaroonbakery.legacy_namespace(),
+ version=_bakery_version(m.version))
+ macaroon._macaroon = m
+ return macaroon
+
+ version = serialized.get('v', None)
+ if version is None:
+ raise ValueError('no version specified')
+ if (version < macaroonbakery.BAKERY_V3 or
+ version > macaroonbakery.LATEST_BAKERY_VERSION):
+ raise ValueError('unknow bakery version {}'.format(version))
+ m = pymacaroons.Macaroon.deserialize(json.dumps(json_macaroon),
+ json_serializer.JsonSerializer())
+ if m.version != macaroon_version(version):
+ raise ValueError(
+ 'underlying macaroon has inconsistent version; '
+ 'got {} want {}'.format(m.version, macaroon_version(version)))
+ namespace = checkers.deserialize_namespace(serialized.get('ns'))
+ cdata = serialized.get('cdata', {})
+ caveat_data = {}
+ for id64 in cdata:
+ id = utils.raw_b64decode(id64)
+ data = utils.raw_b64decode(cdata[id64])
+ caveat_data[id] = data
+ macaroon = Macaroon(root_key=None, id=None,
+ namespace=namespace,
+ version=version)
+ macaroon._caveat_data = caveat_data
+ macaroon._macaroon = m
+ return macaroon
def _new_caveat_id(self, base):
'''Return a third party caveat id
@@ -165,10 +223,44 @@ class Macaroon:
This does not duplicate any third party caveat ids already inside
macaroon. If base is non-empty, it is used as the id prefix.
- @param base string
- @return string
+ @param base bytes
+ @return bytes
'''
- raise NotImplementedError
+ id = bytearray()
+ if len(base) > 0:
+ id.extend(base)
+ else:
+ # Add a version byte to the caveat id. Technically
+ # this is unnecessary as the caveat-decoding logic
+ # that looks at versions should never see this id,
+ # but if the caveat payload isn't provided with the
+ # payload, having this version gives a strong indication
+ # that the payload has been omitted so we can produce
+ # a better error for the user.
+ id.append(macaroonbakery.BAKERY_V3)
+
+ # Iterate through integers looking for one that isn't already used,
+ # starting from n so that if everyone is using this same algorithm,
+ # we'll only perform one iteration.
+ i = len(self._caveat_data)
+ caveats = self._macaroon.caveats
+ while True:
+ # We append a varint to the end of the id and assume that
+ # any client that's created the id that we're using as a base
+ # is using similar conventions - in the worst case they might
+ # end up with a duplicate third party caveat id and thus create
+ # a macaroon that cannot be discharged.
+ temp = id[:]
+ macaroonbakery.encode_uvarint(i, temp)
+ found = False
+ for cav in caveats:
+ if (cav.verification_key_id is not None
+ and cav.caveat_id == temp):
+ found = True
+ break
+ if not found:
+ return bytes(temp)
+ i += 1
def first_party_caveats(self):
'''Return the first party caveats from this macaroon.
@@ -185,6 +277,18 @@ class Macaroon:
'''
return self._macaroon.third_party_caveats()
+ def copy(self):
+ ''' Returns a copy of the macaroon. Note that the the new
+ macaroon's namespace still points to the same underlying Namespace -
+ copying the macaroon does not make a copy of the namespace.
+ :return a Macaroon
+ '''
+ m1 = Macaroon(None, None, version=self._version,
+ namespace=self._namespace)
+ m1._macaroon = self._macaroon.copy()
+ m1._caveat_data = self._caveat_data.copy()
+ return m1
+
def macaroon_version(bakery_version):
'''Return the macaroon version given the bakery version.
@@ -192,12 +296,50 @@ def macaroon_version(bakery_version):
@param bakery_version the bakery version
@return macaroon_version the derived macaroon version
'''
- if bakery_version in [bakery.BAKERY_V0, bakery.BAKERY_V1]:
- return MACAROON_V1
- return MACAROON_V2
+ if bakery_version in [macaroonbakery.BAKERY_V0, macaroonbakery.BAKERY_V1]:
+ return pymacaroons.MACAROON_V1
+ return pymacaroons.MACAROON_V2
+
+
+class ThirdPartyLocator(object):
+ '''Used to find information on third party discharge services.
+ '''
+ __metaclass__ = abc.ABCMeta
+
+ @abc.abstractmethod
+ def third_party_info(self, loc):
+ '''Return information on the third party at the given location.
+ @param loc string
+ @return: a ThirdPartyInfo
+ @raise: ThirdPartyInfoNotFound
+ '''
+ raise NotImplementedError('third_party_info method must be defined in '
+ 'subclass')
+
+
+class ThirdPartyStore(ThirdPartyLocator):
+ ''' Implements a simple in memory ThirdPartyLocator.
+ '''
+ def __init__(self):
+ self._store = {}
+
+ def third_party_info(self, loc):
+ info = self._store.get(loc.rstrip('/'))
+ if info is None:
+ raise macaroonbakery.ThirdPartyInfoNotFound(
+ 'cannot retrieve the info for location {}'.format(loc))
+ return info
+
+ def add_info(self, loc, info):
+ '''Associates the given information with the given location.
+ It will ignore any trailing slash.
+ @param loc the location as string
+ @param info (ThirdPartyInfo) to store for this location.
+ '''
+ self._store[loc.rstrip('/')] = info
-def parse_local_location(loc):
+def _parse_local_location(loc):
'''Parse a local caveat location as generated by LocalThirdPartyCaveat.
This is of the form:
@@ -207,105 +349,53 @@ def parse_local_location(loc):
where <version> is the bakery version of the client that we're
adding the local caveat for.
- It returns false if the location does not represent a local
+ It returns None if the location does not represent a local
caveat location.
- @return a tuple of location and if the location is local.
+ @return a ThirdPartyInfo.
'''
- if not(loc.startswith('local ')):
- return (), False
- v = bakery.BAKERY_V1
+ if not (loc.startswith('local ')):
+ return None
+ v = macaroonbakery.BAKERY_V1
fields = loc.split()
fields = fields[1:] # Skip 'local'
if len(fields) == 2:
try:
v = int(fields[0])
except ValueError:
- return (), False
+ return None
fields = fields[1:]
if len(fields) == 1:
- return (base64.b64decode(fields[0]), v), True
- return (), False
-
-
-class ThirdPartyLocator:
- '''Used to find information on third party discharge services.
- '''
- def __init__(self):
- self._store = {}
-
- def third_party_info(self, loc):
- '''Return information on the third party at the given location.
-
- It returns None if no match is found.
-
- @param loc string
- @return: string
- '''
- return self._store.get(loc)
-
- def add_info(self, loc, info):
- '''Associates the given information with the given location.
-
- It will ignore any trailing slash.
- '''
- self._store[loc.rstrip('\\')] = info
-
-
-class ThirdPartyCaveatInfo:
- '''ThirdPartyCaveatInfo holds the information decoded from
- a third party caveat id.
- '''
- def __init__(self, condition, first_party_public_key, third_party_key_pair,
- root_key, caveat, version, ns):
- '''
- @param condition holds the third party condition to be discharged.
- This is the only field that most third party dischargers will
- need to consider.
- @param first_party_public_key holds the nacl public key of the party
- that created the third party caveat.
- @param third_party_key_pair holds the nacl private used to decrypt
- the caveat - the key pair of the discharging service.
- @param root_key bytes holds the secret root key encoded by the caveat.
- @param caveat holds the full encoded base64 string caveat id from
- which all the other fields are derived.
- @param version holds the version that was used to encode
- the caveat id.
- @params Namespace object that holds the namespace of the first party
- that created the macaroon, as encoded by the party that added the
- third party caveat.
- '''
- self.condition = condition,
- self.first_party_public_key = first_party_public_key,
- self.third_party_key_pair = third_party_key_pair,
- self.root_key = root_key,
- self.caveat = caveat,
- self.version = version,
- self.ns = ns
-
- def __eq__(self, other):
- return (
- self.condition == other.condition and
- self.first_party_public_key == other.first_party_public_key and
- self.third_party_key_pair == other.third_party_key_pair and
- self.caveat == other.caveat and
- self.version == other.version and
- self.ns == other.ns
- )
-
-
-def _field_v2(dict, field):
- val = dict.get(field)
- if val is None:
- return base64.b64decode(dict.get(field + '64'))
- return val
-
-
-def _cav_v2_to_v1(cav):
- val = {
- 'cid': _field_v2(cav, 'i'),
- 'vid': _field_v2(cav, 'v')
- }
- location = cav.get('l')
- if location is not None:
- val['cl'] = location
- return val
+ key = macaroonbakery.PublicKey.deserialize(fields[0])
+ return macaroonbakery.ThirdPartyInfo(public_key=key,
+ version=v)
+ return None
+
+
+def _bakery_version(v):
+ # bakery_version returns a bakery version that corresponds to
+ # the macaroon version v. It is necessarily approximate because
+ # several bakery versions can correspond to a single macaroon
+ # version, so it's only of use when decoding legacy formats
+ #
+ # It will raise a ValueError if it doesn't recognize the version.
+ if v == pymacaroons.MACAROON_V1:
+ # Use version 1 because we don't know of any existing
+ # version 0 clients.
+ return macaroonbakery.BAKERY_V1
+ elif v == pymacaroons.MACAROON_V2:
+ # Note that this could also correspond to Version 3, but
+ # this logic is explicitly for legacy versions.
+ return macaroonbakery.BAKERY_V2
+ else:
+ raise ValueError('unknown macaroon version when deserializing legacy '
+ 'bakery macaroon; got {}'.format(v))
+
+
+class MacaroonJSONEncoder(json.JSONEncoder):
+ def encode(self, m):
+ return m.serialize_json()
+
+
+class MacaroonJSONDecoder(json.JSONDecoder):
+ def decode(self, s, _w=json.decoder.WHITESPACE.match):
+ return Macaroon.deserialize_json(s)
diff --git a/macaroonbakery/oven.py b/macaroonbakery/oven.py
new file mode 100644
index 0000000..69a89cb
--- /dev/null
+++ b/macaroonbakery/oven.py
@@ -0,0 +1,254 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+
+import base64
+import hashlib
+import itertools
+import os
+
+import google
+from pymacaroons import MACAROON_V2, Verifier
+import six
+
+import macaroonbakery
+import macaroonbakery.checkers as checkers
+from macaroonbakery import utils
+from macaroonbakery.internal import id_pb2
+
+
+class Oven:
+ ''' Oven bakes macaroons. They emerge sweet and delicious and ready for use
+ in a Checker.
+
+ All macaroons are associated with one or more operations (see
+ the Op type) which define the capabilities of the macaroon.
+
+ There is one special operation, "login" (defined by LOGIN_OP) which grants
+ the capability to speak for a particular user.
+ The login capability will never be mixed with other capabilities.
+
+ It is up to the caller to decide on semantics for other operations.
+ '''
+
+ def __init__(self, key=None, location=None, locator=None, namespace=None,
+ root_keystore_for_ops=None, ops_store=None):
+ '''
+ @param namespace holds the namespace to use when adding first party
+ caveats.
+ @param root_keystore_for_ops a function that will give the macaroon
+ storage to be used for root keys associated with macaroons created
+ with macaroon.
+ @param ops_store object is used to persistently store the association
+ of multi-op entities with their associated operations when macaroon is
+ called with multiple operations.
+ When this is in use, operation entities with the prefix "multi-" are
+ reserved - a "multi-"-prefixed entity represents a set of operations
+ stored in the OpsStore.
+ @param key holds the private nacl key pair used to encrypt third party
+ caveats. If it is None, no third party caveats can be created.
+ @param location string holds the location that will be associated with
+ new macaroons (as returned by Macaroon.Location).
+ @param locator is used to find out information on third parties when
+ adding third party caveats. If this is None, no non-local third
+ party caveats can be added.
+ '''
+ self.key = key
+ self.location = location
+ self.locator = locator
+ if namespace is None:
+ namespace = checkers.Checker().namespace()
+ self.namespace = namespace
+ self.ops_store = ops_store
+ self.root_keystore_for_ops = root_keystore_for_ops
+ if root_keystore_for_ops is None:
+ my_store = macaroonbakery.MemoryKeyStore()
+ self.root_keystore_for_ops = lambda x: my_store
+
+ def macaroon(self, version, expiry, caveats, ops):
+ ''' Takes a macaroon with the given version from the oven,
+ associates it with the given operations and attaches the given caveats.
+ There must be at least one operation specified.
+ The macaroon will expire at the given time - a time_before first party
+ caveat will be added with that time.
+
+ @return: a new Macaroon object.
+ '''
+ if len(ops) == 0:
+ raise ValueError('cannot mint a macaroon associated '
+ 'with no operations')
+
+ ops = canonical_ops(ops)
+ root_key, storage_id = self.root_keystore_for_ops(ops).root_key()
+
+ id = self._new_macaroon_id(storage_id, expiry, ops)
+
+ id_bytes = six.int2byte(macaroonbakery.LATEST_BAKERY_VERSION) + \
+ id.SerializeToString()
+
+ if macaroonbakery.macaroon_version(version) < MACAROON_V2:
+ # The old macaroon format required valid text for the macaroon id,
+ # so base64-encode it.
+ id_bytes = utils.raw_urlsafe_b64encode(id_bytes)
+
+ m = macaroonbakery.Macaroon(root_key, id_bytes, self.location, version,
+ self.namespace)
+ m.add_caveat(checkers.time_before_caveat(expiry), self.key,
+ self.locator)
+ m.add_caveats(caveats, self.key, self.locator)
+ return m
+
+ def _new_macaroon_id(self, storage_id, expiry, ops):
+ nonce = os.urandom(16)
+ if len(ops) == 1 or self.ops_store is None:
+ return id_pb2.MacaroonId(
+ nonce=nonce,
+ storageId=storage_id,
+ ops=_macaroon_id_ops(ops))
+ # We've got several operations and a multi-op store, so use the store.
+ # TODO use the store only if the encoded macaroon id exceeds some size?
+ entity = self.ops_entity(ops)
+ self.ops_store.put_ops(entity, expiry, ops)
+ return id_pb2.MacaroonId(
+ nonce=nonce,
+ storageId=storage_id,
+ ops=[id_pb2.Op(entity=entity, actions=['*'])])
+
+ def ops_entity(self, ops):
+ ''' Returns a new multi-op entity name string that represents
+ all the given operations and caveats. It returns the same value
+ regardless of the ordering of the operations. It assumes that the
+ operations have been canonicalized and that there's at least one
+ operation.
+
+ :param ops:
+ :return: string that represents all the given operations and caveats.
+ '''
+ # Hash the operations, removing duplicates as we go.
+ hash_entity = hashlib.sha256()
+ for op in ops:
+ hash_entity.update('{}\n{}\n'.format(
+ op.action, op.entity).encode())
+ hash_encoded = base64.urlsafe_b64encode(hash_entity.digest())
+ return 'multi-' + hash_encoded.decode('utf-8').rstrip('=')
+
+ def macaroon_ops(self, macaroons):
+ ''' This method makes the oven satisfy the MacaroonOpStore protocol
+ required by the Checker class.
+
+ For macaroons minted with previous bakery versions, it always
+ returns a single LoginOp operation.
+
+ :param macaroons:
+ :return:
+ '''
+ if len(macaroons) == 0:
+ raise ValueError('no macaroons provided')
+
+ storage_id, ops = _decode_macaroon_id(macaroons[0].identifier_bytes)
+ root_key = self.root_keystore_for_ops(ops).get(storage_id)
+ if root_key is None:
+ raise macaroonbakery.VerificationError(
+ 'macaroon key not found in storage')
+ v = Verifier()
+ conditions = []
+
+ def validator(condition):
+ # Verify the macaroon's signature only. Don't check any of the
+ # caveats yet but save them so that we can return them.
+ conditions.append(condition)
+ return True
+ v.satisfy_general(validator)
+ v.verify(macaroons[0], root_key, macaroons[1:])
+ if (self.ops_store is not None
+ and len(ops) == 1
+ and ops[0].entity.startswith('multi-')):
+ # It's a multi-op entity, so retrieve the actual operations
+ # it's associated with.
+ ops = self.ops_store.get_ops(ops[0].entity)
+
+ return ops, conditions
+
+
+def _decode_macaroon_id(id):
+ storage_id = b''
+ base64_decoded = False
+ first = id[:1]
+ if first == b'A':
+ # The first byte is not a version number and it's 'A', which is the
+ # base64 encoding of the top 6 bits (all zero) of the version number 2
+ # or 3, so we assume that it's the base64 encoding of a new-style
+ # macaroon id, so we base64 decode it.
+ #
+ # Note that old-style ids always start with an ASCII character >= 4
+ # (> 32 in fact) so this logic won't be triggered for those.
+ try:
+ dec = utils.raw_b64decode(id.decode('utf-8'))
+ # Set the id only on success.
+ id = dec
+ base64_decoded = True
+ except:
+ # if it's a bad encoding, we'll get an error which is fine
+ pass
+
+ # Trim any extraneous information from the id before retrieving
+ # it from storage, including the UUID that's added when
+ # creating macaroons to make all macaroons unique even if
+ # they're using the same root key.
+ first = six.byte2int(id[:1])
+ if first == macaroonbakery.BAKERY_V2:
+ # Skip the UUID at the start of the id.
+ storage_id = id[1 + 16:]
+ if first == macaroonbakery.BAKERY_V3:
+ try:
+ id1 = id_pb2.MacaroonId.FromString(id[1:])
+ except google.protobuf.message.DecodeError:
+ raise macaroonbakery.VerificationError(
+ 'no operations found in macaroon')
+ if len(id1.ops) == 0 or len(id1.ops[0].actions) == 0:
+ raise macaroonbakery.VerificationError(
+ 'no operations found in macaroon')
+
+ ops = []
+ for op in id1.ops:
+ for action in op.actions:
+ ops.append(macaroonbakery.Op(op.entity, action))
+ return id1.storageId, ops
+
+ if not base64_decoded and _is_lower_case_hex_char(first):
+ # It's an old-style id, probably with a hyphenated UUID.
+ # so trim that off.
+ last = id.rfind(b'-')
+ if last >= 0:
+ storage_id = id[0:last]
+ return storage_id, [macaroonbakery.LOGIN_OP]
+
+
+def _is_lower_case_hex_char(b):
+ if ord('0') <= b <= ord('9'):
+ return True
+ if ord('a') <= b <= ord('f'):
+ return True
+ return False
+
+
+def canonical_ops(ops):
+ ''' Returns the given operations array sorted with duplicates removed.
+
+ @param ops checker.Ops
+ @return: checker.Ops
+ '''
+ new_ops = sorted(set(ops), key=lambda x: (x.entity, x.action))
+ return new_ops
+
+
+def _macaroon_id_ops(ops):
+ '''Return operations suitable for serializing as part of a MacaroonId.
+
+ It assumes that ops has been canonicalized and that there's at least
+ one operation.
+ '''
+ id_ops = []
+ for entity, entity_ops in itertools.groupby(ops, lambda x: x.entity):
+ actions = map(lambda x: x.action, entity_ops)
+ id_ops.append(id_pb2.Op(entity=entity, actions=actions))
+ return id_ops
diff --git a/macaroonbakery/store.py b/macaroonbakery/store.py
new file mode 100644
index 0000000..ae5f7a7
--- /dev/null
+++ b/macaroonbakery/store.py
@@ -0,0 +1,77 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+import abc
+import os
+
+
+class MemoryOpsStore:
+ ''' A multi-op store that stores the operations in memory.
+ '''
+ def __init__(self):
+ self._store = {}
+
+ def put_ops(self, key, time, ops):
+ ''' Put an ops only if not already there, otherwise it's a no op.
+ '''
+ if self._store.get(key) is None:
+ self._store[key] = ops
+
+ def get_ops(self, key):
+ ''' Returns ops from the key if found otherwise raises a KeyError.
+ '''
+ ops = self._store.get(key)
+ if ops is None:
+ raise KeyError(
+ 'cannot get operations for {}'.format(key))
+ return ops
+
+
+class RootKeyStore(object):
+ ''' Defines a store for macaroon root keys.
+ '''
+ __metaclass__ = abc.ABCMeta
+
+ @abc.abstractmethod
+ def get(self, id):
+ ''' Returns the root key for the given id.
+ If the item is not there, it returns None.
+ @param id: bytes
+ @return: bytes
+ '''
+ raise NotImplementedError('get method must be defined in '
+ 'subclass')
+
+ @abc.abstractmethod
+ def root_key(self):
+ ''' Returns the root key to be used for making a new macaroon, and an
+ id that can be used to look it up later with the get method.
+ Note that the root keys should remain available for as long as the
+ macaroons using them are valid.
+ Note that there is no need for it to return a new root key for every
+ call - keys may be reused, although some key cycling is over time is
+ advisable.
+ @return: bytes
+ '''
+
+
+class MemoryKeyStore(RootKeyStore):
+ ''' MemoryKeyStore returns an implementation of
+ Store that generates a single key and always
+ returns that from root_key. The same id ("0") is always
+ used.
+ '''
+ def __init__(self, key=None):
+ ''' If the key is not specified a random key will be generated.
+ @param key: bytes
+ '''
+ if key is None:
+ key = os.urandom(24)
+ self._key = key
+
+ def get(self, id):
+ if id != b'0':
+ return None
+ return self._key
+
+ def root_key(self):
+ return self._key, b'0'
diff --git a/macaroonbakery/tests/__init__.py b/macaroonbakery/tests/__init__.py
index e69de29..46812ee 100644
--- a/macaroonbakery/tests/__init__.py
+++ b/macaroonbakery/tests/__init__.py
@@ -0,0 +1,2 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
diff --git a/macaroonbakery/tests/common.py b/macaroonbakery/tests/common.py
new file mode 100644
index 0000000..2619127
--- /dev/null
+++ b/macaroonbakery/tests/common.py
@@ -0,0 +1,120 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+from datetime import datetime, timedelta
+
+import pytz
+
+import macaroonbakery
+import macaroonbakery.checkers as checkers
+
+
+class _StoppedClock(object):
+ def __init__(self, t):
+ self.t = t
+
+ def utcnow(self):
+ return self.t
+
+
+epoch = pytz.utc.localize(
+ datetime(year=1900, month=11, day=17, hour=19, minute=00, second=13))
+ages = epoch + timedelta(days=1)
+
+test_context = checkers.context_with_clock(checkers.AuthContext(),
+ _StoppedClock(epoch))
+
+
+def test_checker():
+ c = checkers.Checker()
+ c.namespace().register('testns', '')
+ c.register('str', 'testns', str_check)
+ c.register('true', 'testns', true_check)
+ return c
+
+
+_str_key = checkers.ContextKey('str_check')
+
+
+def str_context(s):
+ return test_context.with_value(_str_key, s)
+
+
+def str_check(ctx, cond, args):
+ expect = ctx[_str_key]
+ if args != expect:
+ return '{} doesn\'t match {}'.format(cond, expect)
+ return None
+
+
+def true_check(ctx, cond, args):
+ # Always succeeds.
+ return None
+
+
+class OneIdentity(macaroonbakery.IdentityClient):
+ '''An IdentityClient implementation that always returns a single identity
+ from declared_identity, allowing allow(LOGIN_OP) to work even when there
+ are no declaration caveats (this is mostly to support the legacy tests
+ which do their own checking of declaration caveats).
+ '''
+
+ def identity_from_context(self, ctx):
+ return None, None
+
+ def declared_identity(self, ctx, declared):
+ return _NoOne()
+
+
+class _NoOne(object):
+ def id(self):
+ return 'noone'
+
+ def domain(self):
+ return ''
+
+
+class ThirdPartyStrcmpChecker(macaroonbakery.ThirdPartyCaveatChecker):
+ def __init__(self, str):
+ self.str = str
+
+ def check_third_party_caveat(self, ctx, cav_info):
+ condition = cav_info.condition
+ if isinstance(cav_info.condition, bytes):
+ condition = cav_info.condition.decode('utf-8')
+ if condition != self.str:
+ raise macaroonbakery.ThirdPartyCaveatCheckFailed(
+ '{} doesn\'t match {}'.format(condition, self.str))
+ return []
+
+
+class ThirdPartyCheckerWithCaveats(macaroonbakery.ThirdPartyCaveatChecker):
+ def __init__(self, cavs=None):
+ if cavs is None:
+ cavs = []
+ self.cavs = cavs
+
+ def check_third_party_caveat(self, ctx, cav_info):
+ return self.cavs
+
+
+class ThirdPartyCaveatCheckerEmpty(macaroonbakery.ThirdPartyCaveatChecker):
+ def check_third_party_caveat(self, ctx, cav_info):
+ return []
+
+
+def new_bakery(location, locator=None):
+ # Returns a new Bakery instance using a new
+ # key pair, and registers the key with the given locator if provided.
+ #
+ # It uses test_checker to check first party caveats.
+ key = macaroonbakery.generate_key()
+ if locator is not None:
+ locator.add_info(location,
+ macaroonbakery.ThirdPartyInfo(
+ public_key=key.public_key,
+ version=macaroonbakery.LATEST_BAKERY_VERSION))
+ return macaroonbakery.Bakery(key=key,
+ checker=test_checker(),
+ location=location,
+ identity_client=OneIdentity(),
+ locator=locator)
diff --git a/macaroonbakery/tests/test_agent.py b/macaroonbakery/tests/test_agent.py
index 86133fe..49134f5 100644
--- a/macaroonbakery/tests/test_agent.py
+++ b/macaroonbakery/tests/test_agent.py
@@ -1,6 +1,5 @@
# Copyright 2017 Canonical Ltd.
# Licensed under the LGPLv3, see LICENCE file for details.
-
import base64
import json
import os
@@ -99,7 +98,7 @@ class TestAgents(TestCase):
agent.load_agent_file(self.no_username_agent_filename)
-agent_file = """
+agent_file = '''
{
"key": {
"public": "YAhRSsth3a36mRYqQGQaLiS4QJax0p356nd+B8x7UQE=",
@@ -113,10 +112,10 @@ agent_file = """
"username": "user-2"
}]
}
-"""
+'''
-bad_key_agent_file = """
+bad_key_agent_file = '''
{
"key": {
"public": "YAhRSsth3a36mRYqQGQaLiS4QJax0p356nd+B8x7UQE=",
@@ -130,10 +129,10 @@ bad_key_agent_file = """
"username": "user-2"
}]
}
-"""
+'''
-no_username_agent_file = """
+no_username_agent_file = '''
{
"key": {
"public": "YAhRSsth3a36mRYqQGQaLiS4QJax0p356nd+B8x7UQE=",
@@ -146,4 +145,4 @@ no_username_agent_file = """
"username": "user-2"
}]
}
-"""
+'''
diff --git a/macaroonbakery/tests/test_authorizer.py b/macaroonbakery/tests/test_authorizer.py
new file mode 100644
index 0000000..da01974
--- /dev/null
+++ b/macaroonbakery/tests/test_authorizer.py
@@ -0,0 +1,132 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+from unittest import TestCase
+
+import macaroonbakery
+import macaroonbakery.checkers as checkers
+
+
+class TestAuthorizer(TestCase):
+ def test_authorize_func(self):
+ def f(ctx, identity, op):
+ self.assertEqual(identity.id(), 'bob')
+ if op.entity == 'a':
+ return False, None
+ elif op.entity == 'b':
+ return True, None
+ elif op.entity == 'c':
+ return True, [checkers.Caveat(location='somewhere',
+ condition='c')]
+ elif op.entity == 'd':
+ return True, [checkers.Caveat(location='somewhere',
+ condition='d')]
+ else:
+ self.fail('unexpected entity: ' + op.Entity)
+
+ ops = [macaroonbakery.Op('a', 'x'), macaroonbakery.Op('b', 'x'),
+ macaroonbakery.Op('c', 'x'), macaroonbakery.Op('d', 'x')]
+ allowed, caveats = macaroonbakery.AuthorizerFunc(f).authorize(
+ checkers.AuthContext(),
+ macaroonbakery.SimpleIdentity('bob'),
+ ops
+ )
+ self.assertEqual(allowed, [False, True, True, True])
+ self.assertEqual(caveats, [
+ checkers.Caveat(location='somewhere', condition='c'),
+ checkers.Caveat(location='somewhere', condition='d')
+ ])
+
+ def test_acl_authorizer(self):
+ ctx = checkers.AuthContext()
+ tests = [
+ ('no ops, no problem',
+ macaroonbakery.ACLAuthorizer(allow_public=True,
+ get_acl=lambda x, y: []), None, [],
+ []),
+ ('identity that does not implement ACLIdentity; '
+ 'user should be denied except for everyone group',
+ macaroonbakery.ACLAuthorizer(allow_public=True,
+ get_acl=lambda ctx, op: [
+ macaroonbakery.EVERYONE]
+ if op.entity == 'a' else ['alice']),
+ SimplestIdentity('bob'),
+ [macaroonbakery.Op(entity='a', action='a'),
+ macaroonbakery.Op(entity='b', action='b')],
+ [True, False]),
+ ('identity that does not implement ACLIdentity with user == Id; '
+ 'user should be denied except for everyone group',
+ macaroonbakery.ACLAuthorizer(allow_public=True,
+ get_acl=lambda ctx, op: [
+ macaroonbakery.EVERYONE] if
+ op.entity == 'a' else ['bob']),
+ SimplestIdentity('bob'),
+ [macaroonbakery.Op(entity='a', action='a'),
+ macaroonbakery.Op(entity='b', action='b')],
+ [True, False]),
+ ('permission denied for everyone without AllowPublic',
+ macaroonbakery.ACLAuthorizer(allow_public=False,
+ get_acl=lambda x, y: [
+ macaroonbakery.EVERYONE]),
+ SimplestIdentity('bob'),
+ [macaroonbakery.Op(entity='a', action='a')],
+ [False]),
+ ('permission granted to anyone with no identity with AllowPublic',
+ macaroonbakery.ACLAuthorizer(allow_public=True,
+ get_acl=lambda x, y: [
+ macaroonbakery.EVERYONE]),
+ None,
+ [macaroonbakery.Op(entity='a', action='a')],
+ [True])
+ ]
+ for test in tests:
+ allowed, caveats = test[1].authorize(ctx, test[2], test[3])
+ self.assertEqual(len(caveats), 0)
+ self.assertEqual(allowed, test[4])
+
+ def test_context_wired_properly(self):
+ ctx = checkers.AuthContext({'a': 'aval'})
+
+ class Visited:
+ in_f = False
+ in_allow = False
+ in_get_acl = False
+
+ def f(ctx, identity, op):
+ self.assertEqual(ctx.get('a'), 'aval')
+ Visited.in_f = True
+ return False, None
+
+ macaroonbakery.AuthorizerFunc(f).authorize(
+ ctx, macaroonbakery.SimpleIdentity('bob'), ['op1']
+ )
+ self.assertTrue(Visited.in_f)
+
+ class TestIdentity(SimplestIdentity, macaroonbakery.ACLIdentity):
+ def allow(other, ctx, acls):
+ self.assertEqual(ctx.get('a'), 'aval')
+ Visited.in_allow = True
+ return False
+
+ def get_acl(ctx, acl):
+ self.assertEqual(ctx.get('a'), 'aval')
+ Visited.in_get_acl = True
+ return []
+
+ macaroonbakery.ACLAuthorizer(allow_public=False,
+ get_acl=get_acl).authorize(
+ ctx, TestIdentity('bob'), ['op1'])
+ self.assertTrue(Visited.in_get_acl)
+ self.assertTrue(Visited.in_allow)
+
+
+class SimplestIdentity(macaroonbakery.Identity):
+ # SimplestIdentity implements Identity for a string. Unlike
+ # SimpleIdentity, it does not implement ACLIdentity.
+ def __init__(self, user):
+ self._identity = user
+
+ def domain(self):
+ return ''
+
+ def id(self):
+ return self._identity
diff --git a/macaroonbakery/tests/test_checker.py b/macaroonbakery/tests/test_checker.py
new file mode 100644
index 0000000..06bf008
--- /dev/null
+++ b/macaroonbakery/tests/test_checker.py
@@ -0,0 +1,963 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+import base64
+from collections import namedtuple
+import json
+from unittest import TestCase
+from datetime import timedelta
+
+from pymacaroons.verifier import Verifier, FirstPartyCaveatVerifierDelegate
+import pymacaroons
+
+import macaroonbakery
+import macaroonbakery.checkers as checkers
+from macaroonbakery.tests.common import test_context, epoch, test_checker
+
+
+class TestChecker(TestCase):
+ def setUp(self):
+ self._discharges = []
+
+ def test_authorize_with_open_access_and_no_macaroons(self):
+ locator = _DischargerLocator()
+ ids = _IdService('ids', locator, self)
+ auth = _OpAuthorizer(
+ {macaroonbakery.Op(entity='something', action='read'):
+ {macaroonbakery.EVERYONE}})
+ ts = _Service('myservice', auth, ids, locator)
+ client = _Client(locator)
+ auth_info = client.do(test_context, ts,
+ [macaroonbakery.Op(entity='something',
+ action='read')])
+ self.assertEqual(len(self._discharges), 0)
+ self.assertIsNotNone(auth_info)
+ self.assertIsNone(auth_info.identity)
+ self.assertEqual(len(auth_info.macaroons), 0)
+
+ def test_authorization_denied(self):
+ locator = _DischargerLocator()
+ ids = _IdService('ids', locator, self)
+ auth = macaroonbakery.ClosedAuthorizer()
+ ts = _Service('myservice', auth, ids, locator)
+ client = _Client(locator)
+ ctx = test_context.with_value(_DISCHARGE_USER_KEY, 'bob')
+ with self.assertRaises(macaroonbakery.PermissionDenied):
+ client.do(ctx, ts, [macaroonbakery.Op(entity='something',
+ action='read')])
+
+ def test_authorize_with_authentication_required(self):
+ locator = _DischargerLocator()
+ ids = _IdService('ids', locator, self)
+ auth = _OpAuthorizer(
+ {macaroonbakery.Op(entity='something', action='read'): {'bob'}})
+ ts = _Service('myservice', auth, ids, locator)
+ client = _Client(locator)
+
+ ctx = test_context.with_value(_DISCHARGE_USER_KEY, 'bob')
+ auth_info = client.do(ctx, ts, [macaroonbakery.Op(entity='something',
+ action='read')])
+ self.assertEqual(self._discharges,
+ [_DischargeRecord(location='ids', user='bob')])
+ self.assertIsNotNone(auth_info)
+ self.assertEqual(auth_info.identity.id(), 'bob')
+ self.assertEqual(len(auth_info.macaroons), 1)
+
+ def test_authorize_multiple_ops(self):
+ locator = _DischargerLocator()
+ ids = _IdService('ids', locator, self)
+ auth = _OpAuthorizer(
+ {
+ macaroonbakery.Op(entity='something', action='read'): {'bob'},
+ macaroonbakery.Op(entity='otherthing', action='read'): {'bob'}
+ }
+ )
+ ts = _Service('myservice', auth, ids, locator)
+ client = _Client(locator)
+ ctx = test_context.with_value(_DISCHARGE_USER_KEY, 'bob')
+ client.do(ctx, ts, [
+ macaroonbakery.Op(entity='something', action='read'),
+ macaroonbakery.Op(entity='otherthing', action='read')
+ ])
+ self.assertEqual(self._discharges,
+ [_DischargeRecord(location='ids', user='bob')])
+
+ def test_capability(self):
+ locator = _DischargerLocator()
+ ids = _IdService('ids', locator, self)
+ auth = _OpAuthorizer(
+ {macaroonbakery.Op(entity='something', action='read'): {'bob'}})
+ ts = _Service('myservice', auth, ids, locator)
+ client = _Client(locator)
+
+ ctx = test_context.with_value(_DISCHARGE_USER_KEY, 'bob')
+ m = client.discharged_capability(
+ ctx, ts, [macaroonbakery.Op(entity='something', action='read')])
+ # Check that we can exercise the capability directly on the service
+ # with no discharging required.
+ auth_info = ts.do(test_context, [m],
+ [macaroonbakery.Op(entity='something',
+ action='read')])
+ self.assertIsNotNone(auth_info)
+ self.assertIsNone(auth_info.identity)
+ self.assertEqual(len(auth_info.macaroons), 1)
+ self.assertEqual(auth_info.macaroons[0][0].identifier_bytes,
+ m[0].identifier_bytes)
+
+ def test_capability_multiple_entities(self):
+ locator = _DischargerLocator()
+ ids = _IdService('ids', locator, self)
+ auth = _OpAuthorizer(
+ {
+ macaroonbakery.Op(entity='e1', action='read'): {'bob'},
+ macaroonbakery.Op(entity='e2', action='read'): {'bob'},
+ macaroonbakery.Op(entity='e3', action='read'): {'bob'},
+ })
+ ts = _Service('myservice', auth, ids, locator)
+ client = _Client(locator)
+ ctx = test_context.with_value(_DISCHARGE_USER_KEY, 'bob')
+ m = client.discharged_capability(ctx, ts, [
+ macaroonbakery.Op(entity='e1',
+ action='read'),
+ macaroonbakery.Op(entity='e2',
+ action='read'),
+ macaroonbakery.Op(entity='e3',
+ action='read')])
+ self.assertEqual(self._discharges,
+ [_DischargeRecord(location='ids', user='bob')])
+
+ # Check that we can exercise the capability directly on the service
+ # with no discharging required.
+ ts.do(test_context, [m], [
+ macaroonbakery.Op(entity='e1', action='read'),
+ macaroonbakery.Op(entity='e2', action='read'),
+ macaroonbakery.Op(entity='e3', action='read')])
+
+ # Check that we can exercise the capability to act on a subset of
+ # the operations.
+ ts.do(test_context, [m], [
+ macaroonbakery.Op(entity='e2', action='read'),
+ macaroonbakery.Op(entity='e3', action='read')]
+ )
+ ts.do(test_context, [m],
+ [macaroonbakery.Op(entity='e3', action='read')])
+
+ def test_multiple_capabilities(self):
+ locator = _DischargerLocator()
+ ids = _IdService('ids', locator, self)
+ auth = _OpAuthorizer(
+ {
+ macaroonbakery.Op(entity='e1', action='read'): {'alice'},
+ macaroonbakery.Op(entity='e2', action='read'): {'bob'},
+ })
+ ts = _Service('myservice', auth, ids, locator)
+
+ # Acquire two capabilities as different users and check
+ # that we can combine them together to do both operations
+ # at once.
+ ctx = test_context.with_value(_DISCHARGE_USER_KEY, 'alice')
+ m1 = _Client(locator).discharged_capability(ctx, ts,
+ [macaroonbakery.Op(
+ entity='e1',
+ action='read')])
+ ctx = test_context.with_value(_DISCHARGE_USER_KEY, 'bob')
+ m2 = _Client(locator).discharged_capability(ctx, ts,
+ [macaroonbakery.Op(
+ entity='e2',
+ action='read')])
+ self.assertEqual(self._discharges,
+ [
+ _DischargeRecord(location='ids', user='alice'),
+ _DischargeRecord(location='ids', user='bob'),
+ ])
+ auth_info = ts.do(test_context, [m1, m2], [
+ macaroonbakery.Op(entity='e1', action='read'),
+ macaroonbakery.Op(entity='e2', action='read')])
+ self.assertIsNotNone(auth_info)
+ self.assertIsNone(auth_info.identity)
+ self.assertEqual(len(auth_info.macaroons), 2)
+ self.assertEqual(auth_info.macaroons[0][0].identifier_bytes,
+ m1[0].identifier_bytes)
+ self.assertEqual(auth_info.macaroons[1][0].identifier_bytes,
+ m2[0].identifier_bytes)
+
+ def test_combine_capabilities(self):
+ locator = _DischargerLocator()
+ ids = _IdService('ids', locator, self)
+ auth = _OpAuthorizer(
+ {
+ macaroonbakery.Op(entity='e1', action='read'): {'alice'},
+ macaroonbakery.Op(entity='e2', action='read'): {'bob'},
+ macaroonbakery.Op(entity='e3', action='read'): {'bob',
+ 'alice'},
+ })
+ ts = _Service('myservice', auth, ids, locator)
+
+ # Acquire two capabilities as different users and check
+ # that we can combine them together into a single capability
+ # capable of both operations.
+ ctx = test_context.with_value(_DISCHARGE_USER_KEY, 'alice')
+ m1 = _Client(locator).discharged_capability(
+ ctx, ts, [
+ macaroonbakery.Op(entity='e1', action='read'),
+ macaroonbakery.Op(entity='e3', action='read')])
+ ctx = test_context.with_value(_DISCHARGE_USER_KEY, 'bob')
+ m2 = _Client(locator).discharged_capability(
+ ctx, ts, [macaroonbakery.Op(entity='e2', action='read')])
+
+ m = ts.capability(test_context, [m1, m2], [
+ macaroonbakery.Op(entity='e1', action='read'),
+ macaroonbakery.Op(entity='e2', action='read'),
+ macaroonbakery.Op(entity='e3', action='read')])
+ ts.do(test_context, [[m.macaroon]], [
+ macaroonbakery.Op(entity='e1', action='read'),
+ macaroonbakery.Op(entity='e2', action='read'),
+ macaroonbakery.Op(entity='e3', action='read')])
+
+ def test_partially_authorized_request(self):
+ locator = _DischargerLocator()
+ ids = _IdService('ids', locator, self)
+ auth = _OpAuthorizer(
+ {
+ macaroonbakery.Op(entity='e1', action='read'): {'alice'},
+ macaroonbakery.Op(entity='e2', action='read'): {'bob'},
+ })
+ ts = _Service('myservice', auth, ids, locator)
+
+ # Acquire a capability for e1 but rely on authentication to
+ # authorize e2.
+ ctx = test_context.with_value(_DISCHARGE_USER_KEY, 'alice')
+ m = _Client(locator).discharged_capability(ctx, ts,
+ [macaroonbakery.Op(
+ entity='e1',
+ action='read')])
+ client = _Client(locator)
+ client.add_macaroon(ts, 'authz', m)
+
+ ctx = test_context.with_value(_DISCHARGE_USER_KEY, 'bob')
+ client.discharged_capability(
+ ctx, ts, [
+ macaroonbakery.Op(entity='e1', action='read'),
+ macaroonbakery.Op(entity='e2', action='read')])
+
+ def test_auth_with_third_party_caveats(self):
+ locator = _DischargerLocator()
+ ids = _IdService('ids', locator, self)
+
+ # We make an authorizer that requires a third party discharge
+ # when authorizing.
+ def authorize_with_tp_discharge(ctx, id, op):
+ if (id is not None and id.id() == 'bob' and
+ op == macaroonbakery.Op(entity='something',
+ action='read')):
+ return True, [checkers.Caveat(condition='question',
+ location='other third party')]
+ return False, None
+
+ auth = macaroonbakery.AuthorizerFunc(authorize_with_tp_discharge)
+ ts = _Service('myservice', auth, ids, locator)
+
+ class _LocalDischargeChecker(macaroonbakery.ThirdPartyCaveatChecker):
+ def check_third_party_caveat(_, ctx, info):
+ if info.condition != 'question':
+ raise ValueError('third party condition not recognized')
+ self._discharges.append(_DischargeRecord(
+ location='other third party',
+ user=ctx.get(_DISCHARGE_USER_KEY)
+ ))
+ return []
+
+ locator['other third party'] = _Discharger(
+ key=macaroonbakery.generate_key(),
+ checker=_LocalDischargeChecker(),
+ locator=locator,
+ )
+ client = _Client(locator)
+ ctx = test_context.with_value(_DISCHARGE_USER_KEY, 'bob')
+ client.do(ctx, ts, [macaroonbakery.Op(entity='something',
+ action='read')])
+ self.assertEqual(self._discharges, [
+ _DischargeRecord(location='ids', user='bob'),
+ _DischargeRecord(location='other third party',
+ user='bob')
+ ])
+
+ def test_capability_combines_first_party_caveats(self):
+ locator = _DischargerLocator()
+ ids = _IdService('ids', locator, self)
+ auth = _OpAuthorizer(
+ {
+ macaroonbakery.Op(entity='e1', action='read'): {'alice'},
+ macaroonbakery.Op(entity='e2', action='read'): {'bob'}
+ }
+ )
+ ts = _Service('myservice', auth, ids, locator)
+
+ # Acquire two capabilities as different users, add some first party
+ # caveats that we can combine them together into a single capability
+ # capable of both operations.
+ ctx = test_context.with_value(_DISCHARGE_USER_KEY, 'alice')
+ m1 = _Client(locator).capability(
+ ctx, ts, [macaroonbakery.Op(entity='e1', action='read')])
+ m1.macaroon.add_first_party_caveat('true 1')
+ m1.macaroon.add_first_party_caveat('true 2')
+ ctx = test_context.with_value(_DISCHARGE_USER_KEY, 'bob')
+ m2 = _Client(locator).capability(
+ ctx, ts, [macaroonbakery.Op(entity='e2', action='read')])
+ m2.macaroon.add_first_party_caveat('true 3')
+ m2.macaroon.add_first_party_caveat('true 4')
+
+ client = _Client(locator)
+ client.add_macaroon(ts, 'authz1', [m1.macaroon])
+ client.add_macaroon(ts, 'authz2', [m2.macaroon])
+
+ m = client.capability(test_context, ts, [
+ macaroonbakery.Op(entity='e1', action='read'),
+ macaroonbakery.Op(entity='e2', action='read')])
+ self.assertEqual(_macaroon_conditions(m.macaroon.caveats, False), [
+ 'true 1',
+ 'true 2',
+ 'true 3',
+ 'true 4',
+ ])
+
+ def test_first_party_caveat_squashing(self):
+ locator = _DischargerLocator()
+ ids = _IdService('ids', locator, self)
+ auth = _OpAuthorizer(
+ {
+ macaroonbakery.Op(entity='e1', action='read'): {'alice'},
+ macaroonbakery.Op(entity='e2', action='read'): {'alice'},
+ })
+ ts = _Service('myservice', auth, ids, locator)
+ tests = [
+ ('duplicates removed', [
+ checkers.Caveat(condition='true 1', namespace='testns'),
+ checkers.Caveat(condition='true 2', namespace='testns'),
+ checkers.Caveat(condition='true 1', namespace='testns'),
+ checkers.Caveat(condition='true 1', namespace='testns'),
+ checkers.Caveat(condition='true 3', namespace='testns'),
+ ], [
+ checkers.Caveat(condition='true 1', namespace='testns'),
+ checkers.Caveat(condition='true 2', namespace='testns'),
+ checkers.Caveat(condition='true 3', namespace='testns'),
+ ]), ('earliest time before', [
+ checkers.time_before_caveat(epoch + timedelta(days=1)),
+ checkers.Caveat(condition='true 1', namespace='testns'),
+ checkers.time_before_caveat(
+ epoch + timedelta(days=0, hours=1)),
+ checkers.time_before_caveat(epoch + timedelta(
+ days=0, hours=0, minutes=5)),
+ ], [
+ checkers.time_before_caveat(epoch + timedelta(
+ days=0, hours=0, minutes=5)),
+ checkers.Caveat(condition='true 1', namespace='testns'),
+ ]), ('operations and declared caveats removed', [
+ checkers.deny_caveat(['foo']),
+ checkers.allow_caveat(['read', 'write']),
+ checkers.declared_caveat('username', 'bob'),
+ checkers.Caveat(condition='true 1', namespace='testns'),
+ ], [
+ checkers.Caveat(condition='true 1', namespace='testns'),
+ ])
+ ]
+ for test in tests:
+ print(test[0])
+
+ # Make a first macaroon with all the required first party caveats.
+ ctx = test_context.with_value(_DISCHARGE_USER_KEY, 'alice')
+ m1 = _Client(locator).capability(
+ ctx, ts, [macaroonbakery.Op(entity='e1', action='read')])
+ m1.add_caveats(test[1], None, None)
+
+ # Make a second macaroon that's not used to check that it's
+ # caveats are not added.
+ m2 = _Client(locator).capability(
+ ctx, ts, [macaroonbakery.Op(entity='e1', action='read')])
+ m2.add_caveat(checkers.Caveat(
+ condition='true notused', namespace='testns'), None, None)
+ client = _Client(locator)
+ client.add_macaroon(ts, 'authz1', [m1.macaroon])
+ client.add_macaroon(ts, 'authz2', [m2.macaroon])
+
+ m3 = client.capability(
+ test_context, ts, [macaroonbakery.Op(entity='e1',
+ action='read')])
+ self.assertEqual(
+ _macaroon_conditions(m3.macaroon.caveats, False),
+ _resolve_caveats(m3.namespace, test[2]))
+
+ def test_login_only(self):
+ locator = _DischargerLocator()
+ ids = _IdService('ids', locator, self)
+ auth = macaroonbakery.ClosedAuthorizer()
+ ts = _Service('myservice', auth, ids, locator)
+
+ ctx = test_context.with_value(_DISCHARGE_USER_KEY, 'bob')
+ auth_info = _Client(locator).do(ctx, ts, [macaroonbakery.LOGIN_OP])
+ self.assertIsNotNone(auth_info)
+ self.assertEqual(auth_info.identity.id(), 'bob')
+
+ def test_allow_any(self):
+ locator = _DischargerLocator()
+ ids = _IdService('ids', locator, self)
+ auth = _OpAuthorizer(
+ {
+ macaroonbakery.Op(entity='e1', action='read'): {'alice'},
+ macaroonbakery.Op(entity='e2', action='read'): {'bob'},
+ })
+ ts = _Service('myservice', auth, ids, locator)
+
+ # Acquire a capability for e1 but rely on authentication to
+ # authorize e2.
+ ctx = test_context.with_value(_DISCHARGE_USER_KEY, 'alice')
+ m = _Client(locator).discharged_capability(ctx, ts,
+ [macaroonbakery.Op(
+ entity='e1',
+ action='read')])
+
+ client = _Client(locator)
+ client.add_macaroon(ts, 'authz', m)
+
+ self._discharges = []
+ ctx = test_context.with_value(_DISCHARGE_USER_KEY, 'bob')
+ with self.assertRaises(_DischargeRequiredError):
+ client.do_any(
+ ctx, ts, [
+ macaroonbakery.LOGIN_OP,
+ macaroonbakery.Op(entity='e1', action='read'),
+ macaroonbakery.Op(entity='e1', action='read')
+ ]
+ )
+ self.assertEqual(len(self._discharges), 0)
+
+ # Log in as bob.
+ _, err = client.do(ctx, ts, [macaroonbakery.LOGIN_OP])
+
+ # All the previous actions should now be allowed.
+ auth_info, allowed = client.do_any(
+ ctx, ts, [
+ macaroonbakery.LOGIN_OP,
+ macaroonbakery.Op(entity='e1', action='read'),
+ macaroonbakery.Op(entity='e1', action='read')
+ ]
+ )
+ self.assertEqual(auth_info.identity.id(), 'bob')
+ self.assertEqual(len(auth_info.macaroons), 2)
+ self.assertEqual(allowed, [True, True, True])
+
+ def test_auth_with_identity_from_context(self):
+ locator = _DischargerLocator()
+ ids = _BasicAuthIdService()
+ auth = _OpAuthorizer(
+ {
+ macaroonbakery.Op(entity='e1', action='read'): {'sherlock'},
+ macaroonbakery.Op(entity='e2', action='read'): {'bob'},
+ })
+ ts = _Service('myservice', auth, ids, locator)
+
+ # Check that we can perform the ops with basic auth in the
+ # context.
+ ctx = _context_with_basic_auth(test_context, 'sherlock', 'holmes')
+ auth_info = _Client(locator).do(
+ ctx, ts, [macaroonbakery.Op(entity='e1', action='read')])
+ self.assertEqual(auth_info.identity.id(), 'sherlock')
+ self.assertEqual(len(auth_info.macaroons), 0)
+
+ def test_auth_login_op_with_identity_from_context(self):
+ locator = _DischargerLocator()
+ ids = _BasicAuthIdService()
+ ts = _Service('myservice', macaroonbakery.ClosedAuthorizer(),
+ ids, locator)
+
+ # Check that we can use LoginOp
+ # when auth isn't granted through macaroons.
+ ctx = _context_with_basic_auth(test_context, 'sherlock', 'holmes')
+ auth_info = _Client(locator).do(ctx, ts, [macaroonbakery.LOGIN_OP])
+ self.assertEqual(auth_info.identity.id(), 'sherlock')
+ self.assertEqual(len(auth_info.macaroons), 0)
+
+ def test_operation_allow_caveat(self):
+ locator = _DischargerLocator()
+ ids = _IdService('ids', locator, self)
+ auth = _OpAuthorizer(
+ {
+ macaroonbakery.Op(entity='e1', action='read'): {'bob'},
+ macaroonbakery.Op(entity='e1', action='write'): {'bob'},
+ macaroonbakery.Op(entity='e2', action='read'): {'bob'},
+ })
+ ts = _Service('myservice', auth, ids, locator)
+ client = _Client(locator)
+
+ ctx = test_context.with_value(_DISCHARGE_USER_KEY, 'bob')
+ m = client.capability(
+ ctx, ts, [
+ macaroonbakery.Op(entity='e1', action='read'),
+ macaroonbakery.Op(entity='e1', action='write'),
+ macaroonbakery.Op(entity='e2', action='read')])
+
+ # Sanity check that we can do a write.
+ ts.do(test_context, [[m.macaroon]],
+ [macaroonbakery.Op(entity='e1', action='write')])
+
+ m.add_caveat(checkers.allow_caveat(['read']), None, None)
+
+ # A read operation should work.
+ ts.do(test_context, [[m.macaroon]], [
+ macaroonbakery.Op(entity='e1', action='read'),
+ macaroonbakery.Op(entity='e2', action='read')])
+
+ # A write operation should fail
+ # even though the original macaroon allowed it.
+ with self.assertRaises(_DischargeRequiredError):
+ ts.do(test_context, [[m.macaroon]], [
+ macaroonbakery.Op(entity='e1', action='write')])
+
+ def test_operation_deny_caveat(self):
+ locator = _DischargerLocator()
+ ids = _IdService('ids', locator, self)
+ auth = _OpAuthorizer(
+ {
+ macaroonbakery.Op(entity='e1', action='read'): {'bob'},
+ macaroonbakery.Op(entity='e1', action='write'): {'bob'},
+ macaroonbakery.Op(entity='e2', action='read'): {'bob'},
+ })
+ ts = _Service('myservice', auth, ids, locator)
+ client = _Client(locator)
+
+ ctx = test_context.with_value(_DISCHARGE_USER_KEY, 'bob')
+ m = client.capability(
+ ctx, ts, [
+ macaroonbakery.Op(entity='e1', action='read'),
+ macaroonbakery.Op(entity='e1', action='write'),
+ macaroonbakery.Op(entity='e2', action='read')])
+
+ # Sanity check that we can do a write.
+ ts.do(test_context, [[m.macaroon]], [
+ macaroonbakery.Op(entity='e1', action='write')])
+
+ m.add_caveat(checkers.deny_caveat(['write']), None, None)
+
+ # A read operation should work.
+ ts.do(
+ test_context, [[m.macaroon]], [
+ macaroonbakery.Op(entity='e1', action='read'),
+ macaroonbakery.Op(entity='e2', action='read')])
+
+ # A write operation should fail
+ # even though the original macaroon allowed it.
+ with self.assertRaises(_DischargeRequiredError):
+ ts.do(test_context, [[m.macaroon]], [
+ macaroonbakery.Op(entity='e1', action='write')])
+
+ def test_duplicate_login_macaroons(self):
+ locator = _DischargerLocator()
+ ids = _IdService('ids', locator, self)
+ auth = macaroonbakery.ClosedAuthorizer()
+ ts = _Service('myservice', auth, ids, locator)
+
+ # Acquire a login macaroon for bob.
+ client1 = _Client(locator)
+ ctx = test_context.with_value(_DISCHARGE_USER_KEY, 'bob')
+ auth_info = client1.do(ctx, ts, [macaroonbakery.LOGIN_OP])
+ self.assertEqual(auth_info.identity.id(), 'bob')
+
+ # Acquire a login macaroon for alice.
+ client2 = _Client(locator)
+ ctx = test_context.with_value(_DISCHARGE_USER_KEY, 'alice')
+ auth_info = client2.do(ctx, ts, [macaroonbakery.LOGIN_OP])
+ self.assertEqual(auth_info.identity.id(), 'alice')
+
+ # Combine the two login macaroons into one client.
+ client3 = _Client(locator)
+ client3.add_macaroon(ts, '1.bob',
+ client1._macaroons[ts.name()]['authn'])
+ client3.add_macaroon(ts, '2.alice',
+ client2._macaroons[ts.name()]['authn'])
+
+ # We should authenticate as bob (because macaroons are presented
+ # ordered by "cookie" name)
+ auth_info = client3.do(test_context, ts, [macaroonbakery.LOGIN_OP])
+ self.assertEqual(auth_info.identity.id(), 'bob')
+ self.assertEqual(len(auth_info.macaroons), 1)
+
+ # Try them the other way around and we should authenticate as alice.
+ client3 = _Client(locator)
+ client3.add_macaroon(ts, '1.alice',
+ client2._macaroons[ts.name()]['authn'])
+ client3.add_macaroon(ts, '2.bob',
+ client1._macaroons[ts.name()]['authn'])
+
+ auth_info = client3.do(test_context, ts, [macaroonbakery.LOGIN_OP])
+ self.assertEqual(auth_info.identity.id(), 'alice')
+ self.assertEqual(len(auth_info.macaroons), 1)
+
+ def test_macaroon_ops_fatal_error(self):
+ # When we get a non-VerificationError error from the
+ # opstore, we don't do any more verification.
+ checker = macaroonbakery.Checker(
+ macaroon_opstore=_MacaroonStoreWithError())
+ m = pymacaroons.Macaroon(version=pymacaroons.MACAROON_V2)
+ with self.assertRaises(ValueError):
+ checker.auth([m]).allow(test_context, [macaroonbakery.LOGIN_OP])
+
+
+class _DischargerLocator(object):
+ def __init__(self, dischargers=None):
+ if dischargers is None:
+ dischargers = {}
+ self._dischargers = dischargers
+
+ def third_party_info(self, loc):
+ d = self._dischargers.get(loc)
+ if d is None:
+ return None
+ return macaroonbakery.ThirdPartyInfo(
+ public_key=d._key.public_key,
+ version=macaroonbakery.LATEST_BAKERY_VERSION,
+ )
+
+ def __setitem__(self, key, item):
+ self._dischargers[key] = item
+
+ def __getitem__(self, key):
+ return self._dischargers[key]
+
+ def get(self, key):
+ return self._dischargers.get(key)
+
+
+class _IdService(macaroonbakery.IdentityClient,
+ macaroonbakery.ThirdPartyCaveatChecker):
+ def __init__(self, location, locator, test_class):
+ self._location = location
+ self._test = test_class
+ key = macaroonbakery.generate_key()
+ self._discharger = _Discharger(key=key, checker=self, locator=locator)
+ locator[location] = self._discharger
+
+ def check_third_party_caveat(self, ctx, info):
+ if info.condition != 'is-authenticated-user':
+ raise macaroonbakery.CaveatNotRecognizedError(
+ 'third party condition not '
+ 'recognized')
+
+ username = ctx.get(_DISCHARGE_USER_KEY, '')
+ if username == '':
+ return macaroonbakery.ThirdPartyCaveatCheckFailed(
+ 'no current user')
+ self._test._discharges.append(
+ _DischargeRecord(location=self._location, user=username))
+ return [checkers.declared_caveat('username', username)]
+
+ def identity_from_context(self, ctx):
+ return None, [checkers.Caveat(location=self._location,
+ condition='is-authenticated-user')]
+
+ def declared_identity(self, ctx, declared):
+ user = declared.get('username')
+ if user is None:
+ raise macaroonbakery.IdentityError('no username declared')
+ return macaroonbakery.SimpleIdentity(user)
+
+
+_DISCHARGE_USER_KEY = checkers.ContextKey('user-key')
+
+_DischargeRecord = namedtuple('_DISCHARGE_RECORD', ['location', 'user'])
+
+
+class _Discharger(object):
+ ''' utility class that has a discharge function with the same signature of
+ get_discharge for discharge_all.
+ '''
+
+ def __init__(self, key, locator, checker):
+ self._key = key
+ self._locator = locator
+ self._checker = checker
+
+ def discharge(self, ctx, cav, payload):
+ return macaroonbakery.discharge(ctx, key=self._key, id=cav.caveat_id,
+ caveat=payload,
+ checker=self._checker,
+ locator=self._locator)
+
+
+class _OpAuthorizer(macaroonbakery.Authorizer):
+ '''Implements bakery.Authorizer by looking the operation
+ up in the given map. If the username is in the associated list
+ or the list contains "everyone", authorization is granted.
+ '''
+
+ def __init__(self, auth=None):
+ if auth is None:
+ auth = {}
+ self._auth = auth
+
+ def authorize(self, ctx, id, ops):
+ return macaroonbakery.ACLAuthorizer(
+ allow_public=True,
+ get_acl=lambda ctx, op: self._auth.get(op, [])).authorize(
+ ctx, id, ops)
+
+
+class _MacaroonStore(object):
+ ''' Stores root keys in memory and puts all operations in the macaroon id.
+ '''
+
+ def __init__(self, key, locator):
+ self._root_key_store = macaroonbakery.MemoryKeyStore()
+ self._key = key
+ self._locator = locator
+
+ def new_macaroon(self, caveats, namespace, ops):
+ root_key, id = self._root_key_store.root_key()
+ m_id = {'id': base64.urlsafe_b64encode(id).decode('utf-8'), 'ops': ops}
+ data = json.dumps(m_id)
+ m = macaroonbakery.Macaroon(
+ root_key=root_key, id=data, location='',
+ version=macaroonbakery.LATEST_BAKERY_VERSION,
+ namespace=namespace)
+ m.add_caveats(caveats, self._key, self._locator)
+ return m
+
+ def macaroon_ops(self, ms):
+ if len(ms) == 0:
+ raise ValueError('no macaroons provided')
+
+ m_id = json.loads(ms[0].identifier_bytes.decode('utf-8'))
+ root_key = self._root_key_store.get(
+ base64.urlsafe_b64decode(m_id['id'].encode('utf-8')))
+
+ v = Verifier()
+
+ class NoValidationOnFirstPartyCaveat(FirstPartyCaveatVerifierDelegate):
+ def verify_first_party_caveat(self, verifier, caveat, signature):
+ return True
+
+ v.first_party_caveat_verifier_delegate = \
+ NoValidationOnFirstPartyCaveat()
+ ok = v.verify(macaroon=ms[0], key=root_key,
+ discharge_macaroons=ms[1:])
+ if not ok:
+ raise macaroonbakery.VerificationError('invalid signature')
+ conditions = []
+ for m in ms:
+ cavs = m.first_party_caveats()
+ for cav in cavs:
+ conditions.append(cav.caveat_id_bytes.decode('utf-8'))
+ ops = []
+ for op in m_id['ops']:
+ ops.append(macaroonbakery.Op(entity=op[0], action=op[1]))
+ return ops, conditions
+
+
+class _Service(object):
+ '''Represents a service that requires authorization.
+
+ Clients can make requests to the service to perform operations
+ and may receive a macaroon to discharge if the authorization
+ process requires it.
+ '''
+
+ def __init__(self, name, auth, idm, locator):
+ self._name = name
+ self._store = _MacaroonStore(macaroonbakery.generate_key(), locator)
+ self._checker = macaroonbakery.Checker(
+ checker=test_checker(),
+ authorizer=auth,
+ identity_client=idm,
+ macaroon_opstore=self._store)
+
+ def name(self):
+ return self._name
+
+ def do(self, ctx, ms, ops):
+ try:
+ authInfo = self._checker.auth(ms).allow(ctx, ops)
+ except macaroonbakery.DischargeRequiredError as exc:
+ self._discharge_required_error(exc)
+ return authInfo
+
+ def do_any(self, ctx, ms, ops):
+ # makes a request to the service to perform any of the given
+ # operations. It reports which operations have succeeded.
+ try:
+ authInfo, allowed = self._checker.auth(ms).allow_any(ctx, ops)
+ return authInfo, allowed
+ except macaroonbakery.DischargeRequiredError as exc:
+ self._discharge_required_error(exc)
+
+ def capability(self, ctx, ms, ops):
+ try:
+ conds = self._checker.auth(ms).allow_capability(ctx, ops)
+ except macaroonbakery.DischargeRequiredError as exc:
+ self._discharge_required_error(exc)
+
+ m = self._store.new_macaroon(None, self._checker.namespace(), ops)
+ for cond in conds:
+ m.macaroon.add_first_party_caveat(cond)
+ return m
+
+ def _discharge_required_error(self, err):
+ m = self._store.new_macaroon(err.cavs(), self._checker.namespace(),
+ err.ops())
+ name = 'authz'
+ if len(err.ops()) == 1 and err.ops()[0] == macaroonbakery.LOGIN_OP:
+ name = 'authn'
+ raise _DischargeRequiredError(name=name, m=m)
+
+
+class _DischargeRequiredError(Exception):
+ def __init__(self, name, m):
+ Exception.__init__(self, 'discharge required')
+ self._name = name
+ self._m = m
+
+ def m(self):
+ return self._m
+
+ def name(self):
+ return self._name
+
+
+class _Client(object):
+ max_retries = 3
+
+ def __init__(self, dischargers):
+ self._key = macaroonbakery.generate_key()
+ self._macaroons = {}
+ self._dischargers = dischargers
+
+ def do(self, ctx, svc, ops):
+ class _AuthInfo:
+ authInfo = None
+
+ def svc_do(ms):
+ _AuthInfo.authInfo = svc.do(ctx, ms, ops)
+
+ self._do_func(ctx, svc, svc_do)
+ return _AuthInfo.authInfo
+
+ def do_any(self, ctx, svc, ops):
+ return svc.do_any(ctx, self._request_macaroons(svc), ops)
+
+ def capability(self, ctx, svc, ops):
+ # capability returns a capability macaroon for the given operations.
+
+ class _M:
+ m = None
+
+ def svc_capability(ms):
+ _M.m = svc.capability(ctx, ms, ops)
+ return
+
+ self._do_func(ctx, svc, svc_capability)
+ return _M.m
+
+ def discharged_capability(self, ctx, svc, ops):
+ m = self.capability(ctx, svc, ops)
+ return self._discharge_all(ctx, m)
+
+ def _do_func(self, ctx, svc, f):
+ for i in range(0, self.max_retries):
+ try:
+ f(self._request_macaroons(svc))
+ return
+ except _DischargeRequiredError as exc:
+ ms = self._discharge_all(ctx, exc.m())
+ self.add_macaroon(svc, exc.name(), ms)
+ raise ValueError('discharge failed too many times')
+
+ def _clear_macaroons(self, svc):
+ if svc is None:
+ self._macaroons = {}
+ return
+ if svc.name() in self._macaroons:
+ del self._macaroons[svc.name()]
+
+ def add_macaroon(self, svc, name, m):
+ if svc.name() not in self._macaroons:
+ self._macaroons[svc.name()] = {}
+ self._macaroons[svc.name()][name] = m
+
+ def _request_macaroons(self, svc):
+ mmap = self._macaroons.get(svc.name(), [])
+ # Put all the macaroons in the slice ordered by key
+ # so that we have deterministic behaviour in the tests.
+ names = []
+ for name in mmap:
+ names.append(name)
+ names = sorted(names)
+ ms = [None] * len(names)
+ for i, name in enumerate(names):
+ ms[i] = mmap[name]
+ return ms
+
+ def _discharge_all(self, ctx, m):
+ def get_discharge(ctx, cav, pay_load):
+ d = self._dischargers.get(cav.location)
+ if d is None:
+ raise ValueError('third party discharger '
+ '{} not found'.format(cav.location))
+ return d.discharge(ctx, cav, pay_load)
+
+ return macaroonbakery.discharge_all(ctx, m, get_discharge)
+
+
+class _BasicAuthIdService(macaroonbakery.IdentityClient):
+ def identity_from_context(self, ctx):
+ user, pwd = _basic_auth_from_context(ctx)
+ if user != 'sherlock' or pwd != 'holmes':
+ return None, None
+ return macaroonbakery.SimpleIdentity(user), None
+
+ def declared_identity(self, ctx, declared):
+ raise macaroonbakery.IdentityError('no identity declarations in basic '
+ 'auth id service')
+
+
+_BASIC_AUTH_KEY = checkers.ContextKey('user-key')
+
+
+class _BasicAuth(object):
+ def __init__(self, user, password):
+ self.user = user
+ self.password = password
+
+
+def _context_with_basic_auth(ctx, user, password):
+ return ctx.with_value(_BASIC_AUTH_KEY, _BasicAuth(user, password))
+
+
+def _basic_auth_from_context(ctx):
+ auth = ctx.get(_BASIC_AUTH_KEY, _BasicAuth('', ''))
+ return auth.user, auth.password
+
+
+def _macaroon_conditions(caveats, allow_third):
+ conds = [''] * len(caveats)
+ for i, cav in enumerate(caveats):
+ if cav.location is not None and cav.location != '':
+ if not allow_third:
+ raise ValueError('found unexpected third party caveat:'
+ ' {}'.format(cav.location))
+ continue
+ conds[i] = cav.caveat_id.decode('utf-8')
+ return conds
+
+
+def _resolve_caveats(ns, caveats):
+ conds = [''] * len(caveats)
+ for i, cav in enumerate(caveats):
+ if cav.location is not None and cav.location != '':
+ raise ValueError('found unexpected third party caveat')
+ conds[i] = ns.resolve_caveat(cav).condition
+ return conds
+
+
+class _MacaroonStoreWithError(object):
+ def new_macaroon(self, caveats, ns, ops):
+ raise ValueError('some error')
+
+ def macaroon_ops(self, ms):
+ raise ValueError('some error')
diff --git a/macaroonbakery/tests/test_checkers.py b/macaroonbakery/tests/test_checkers.py
new file mode 100644
index 0000000..f552fa4
--- /dev/null
+++ b/macaroonbakery/tests/test_checkers.py
@@ -0,0 +1,356 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+from datetime import datetime, timedelta
+from unittest import TestCase
+
+import six
+import pytz
+from pymacaroons import Macaroon, MACAROON_V2
+
+import macaroonbakery.checkers as checkers
+
+# A frozen time for the tests.
+NOW = datetime(
+ year=2006, month=1, day=2, hour=15, minute=4, second=5, microsecond=123)
+
+
+class TestClock():
+ def utcnow(self):
+ return pytz.UTC.localize(NOW)
+
+
+class TestCheckers(TestCase):
+ def test_checkers(self):
+
+ tests = [
+ ('nothing in context, no extra checkers', [
+ ('something',
+ 'caveat "something" not satisfied: caveat not recognized'),
+ ('', 'cannot parse caveat "": empty caveat'),
+ (' hello', 'cannot parse caveat " hello": caveat starts with'
+ ' space character'),
+ ], None),
+ ('one failed caveat', [
+ ('t:a aval', None),
+ ('t:b bval', None),
+ ('t:a wrong', 'caveat "t:a wrong" not satisfied: wrong arg'),
+ ], None),
+ ('time from clock', [
+ (checkers.time_before_caveat(
+ datetime.utcnow() +
+ timedelta(0, 1)).condition,
+ None),
+ (checkers.time_before_caveat(NOW).condition,
+ 'caveat "time-before 2006-01-02T15:04:05.000123Z" '
+ 'not satisfied: macaroon has expired'),
+ (checkers.time_before_caveat(NOW - timedelta(0, 1)).condition,
+ 'caveat "time-before 2006-01-02T15:04:04.000123Z" '
+ 'not satisfied: macaroon has expired'),
+ ('time-before bad-date',
+ 'caveat "time-before bad-date" not satisfied: '
+ 'cannot parse "bad-date" as RFC 3339'),
+ (checkers.time_before_caveat(NOW).condition + " ",
+ 'caveat "time-before 2006-01-02T15:04:05.000123Z " '
+ 'not satisfied: '
+ 'cannot parse "2006-01-02T15:04:05.000123Z " as RFC 3339'),
+ ], lambda x: checkers.context_with_clock(ctx, TestClock())),
+ ('real time', [
+ (checkers.time_before_caveat(datetime(
+ year=2010, month=1, day=1)).condition,
+ 'caveat "time-before 2010-01-01T00:00:00.000000Z" not '
+ 'satisfied: macaroon has expired'),
+ (checkers.time_before_caveat(datetime(
+ year=3000, month=1, day=1)).condition, None),
+ ], None),
+ ('declared, no entries', [
+ (checkers.declared_caveat('a', 'aval').condition,
+ 'caveat "declared a aval" not satisfied: got a=null, '
+ 'expected "aval"'),
+ (checkers.COND_DECLARED, 'caveat "declared" not satisfied: '
+ 'declared caveat has no value'),
+ ], None),
+ ('declared, some entries', [
+ (checkers.declared_caveat('a', 'aval').condition, None),
+ (checkers.declared_caveat('b', 'bval').condition, None),
+ (checkers.declared_caveat('spc', ' a b').condition, None),
+ (checkers.declared_caveat('a', 'bval').condition,
+ 'caveat "declared a bval" not satisfied: '
+ 'got a="aval", expected "bval"'),
+ (checkers.declared_caveat('a', ' aval').condition,
+ 'caveat "declared a aval" not satisfied: '
+ 'got a="aval", expected " aval"'),
+ (checkers.declared_caveat('spc', 'a b').condition,
+ 'caveat "declared spc a b" not satisfied: '
+ 'got spc=" a b", expected "a b"'),
+ (checkers.declared_caveat('', 'a b').condition,
+ 'caveat "error invalid caveat \'declared\' key """ '
+ 'not satisfied: bad caveat'),
+ (checkers.declared_caveat('a b', 'a b').condition,
+ 'caveat "error invalid caveat \'declared\' key "a b"" '
+ 'not satisfied: bad caveat'),
+ ], lambda x: checkers.context_with_declared(x, {
+ 'a': 'aval',
+ 'b': 'bval',
+ 'spc': ' a b'})),
+ ]
+ checker = checkers.Checker()
+ checker.namespace().register('testns', 't')
+ checker.register('a', 'testns', arg_checker(self, 't:a', 'aval'))
+ checker.register('b', 'testns', arg_checker(self, 't:b', 'bval'))
+ ctx = checkers.AuthContext()
+ for test in tests:
+ print(test[0])
+ if test[2] is not None:
+ ctx1 = test[2](ctx)
+ else:
+ ctx1 = ctx
+ for check in test[1]:
+ err = checker.check_first_party_caveat(ctx1, check[0])
+ if check[1] is not None:
+ self.assertEqual(err, check[1])
+ else:
+ self.assertIsNone(err)
+
+ def test_infer_declared(self):
+ tests = [
+ ('no macaroons', [], {}, None),
+ ('single macaroon with one declaration', [
+ [checkers.Caveat(condition='declared foo bar')]
+ ], {'foo': 'bar'}, None),
+ ('only one argument to declared', [
+ [checkers.Caveat(condition='declared foo')]
+ ], {}, None),
+ ('spaces in value', [
+ [checkers.Caveat(condition='declared foo bar bloggs')]
+ ], {'foo': 'bar bloggs'}, None),
+ ('attribute with declared prefix', [
+ [checkers.Caveat(condition='declaredccf foo')]
+ ], {}, None),
+ ('several macaroons with different declares', [
+ [
+ checkers.declared_caveat('a', 'aval'),
+ checkers.declared_caveat('b', 'bval')
+ ], [
+ checkers.declared_caveat('c', 'cval'),
+ checkers.declared_caveat('d', 'dval')
+ ]
+ ], {'a': 'aval', 'b': 'bval', 'c': 'cval', 'd': 'dval'}, None),
+ ('duplicate values', [
+ [
+ checkers.declared_caveat('a', 'aval'),
+ checkers.declared_caveat('a', 'aval'),
+ checkers.declared_caveat('b', 'bval')
+ ], [
+ checkers.declared_caveat('a', 'aval'),
+ checkers.declared_caveat('b', 'bval'),
+ checkers.declared_caveat('c', 'cval'),
+ checkers.declared_caveat('d', 'dval')
+ ]
+ ], {'a': 'aval', 'b': 'bval', 'c': 'cval', 'd': 'dval'}, None),
+ ('conflicting values', [
+ [
+ checkers.declared_caveat('a', 'aval'),
+ checkers.declared_caveat('a', 'conflict'),
+ checkers.declared_caveat('b', 'bval')
+ ], [
+ checkers.declared_caveat('a', 'conflict'),
+ checkers.declared_caveat('b', 'another conflict'),
+ checkers.declared_caveat('c', 'cval'),
+ checkers.declared_caveat('d', 'dval')
+ ]
+ ], {'c': 'cval', 'd': 'dval'}, None),
+ ('third party caveats ignored', [
+ [checkers.Caveat(condition='declared a no conflict',
+ location='location')],
+ [checkers.declared_caveat('a', 'aval')]
+ ], {'a': 'aval'}, None),
+ ('unparseable caveats ignored', [
+ [checkers.Caveat(condition=' bad')],
+ [checkers.declared_caveat('a', 'aval')]
+ ], {'a': 'aval'}, None),
+ ('infer with namespace', [
+ [
+ checkers.declared_caveat('a', 'aval'),
+ caveat_with_ns(checkers.declared_caveat('a', 'aval'),
+ 'testns'),
+ ]
+ ], {'a': 'aval'}, None),
+ ]
+ for test in tests:
+ uri_to_prefix = test[3]
+ if uri_to_prefix is None:
+ uri_to_prefix = {checkers.STD_NAMESPACE: ''}
+ ns = checkers.Namespace(uri_to_prefix)
+ print(test[0])
+ ms = []
+ for i, caveats in enumerate(test[1]):
+ m = Macaroon(key=None, identifier=six.int2byte(i), location='',
+ version=MACAROON_V2)
+ for cav in caveats:
+ cav = ns.resolve_caveat(cav)
+ if cav.location == '':
+ m.add_first_party_caveat(cav.condition)
+ else:
+ m.add_third_party_caveat(cav.location, None,
+ cav.condition)
+ ms.append(m)
+ self.assertEqual(checkers.infer_declared(ms), test[2])
+
+ def test_operations_checker(self):
+ tests = [
+ ('all allowed', checkers.allow_caveat(
+ ['op1', 'op2', 'op4', 'op3']),
+ ['op1', 'op3', 'op2'], None),
+ ('none denied', checkers.deny_caveat(['op1', 'op2']),
+ ['op3', 'op4'], None),
+ ('one not allowed', checkers.allow_caveat(['op1', 'op2']),
+ ['op1', 'op3'],
+ 'caveat "allow op1 op2" not satisfied: op3 not allowed'),
+ ('one not denied', checkers.deny_caveat(['op1', 'op2']),
+ ['op4', 'op5', 'op2'],
+ 'caveat "deny op1 op2" not satisfied: op2 not allowed'),
+ ('no operations, allow caveat', checkers.allow_caveat(['op1']),
+ [],
+ 'caveat "allow op1" not satisfied: op1 not allowed'),
+ ('no operations, deny caveat', checkers.deny_caveat(['op1']),
+ [], None),
+ ('no operations, empty allow caveat', checkers.Caveat(
+ condition=checkers.COND_ALLOW),
+ [], 'caveat "allow" not satisfied: no operations allowed'),
+ ]
+ checker = checkers.Checker()
+ for test in tests:
+ print(test[0])
+ ctx = checkers.context_with_operations(checkers.AuthContext(),
+ test[2])
+ err = checker.check_first_party_caveat(ctx, test[1].condition)
+ if test[3] is None:
+ self.assertIsNone(err)
+ continue
+ self.assertEqual(err, test[3])
+
+ def test_operation_error_caveat(self):
+ tests = [
+ ('empty allow', checkers.allow_caveat(None),
+ 'error no operations allowed'),
+ ('allow: invalid operation name',
+ checkers.allow_caveat(['op1', 'operation number 2']),
+ 'error invalid operation name "operation number 2"'),
+ ('deny: invalid operation name',
+ checkers.deny_caveat(['op1', 'operation number 2']),
+ 'error invalid operation name "operation number 2"')
+ ]
+ for test in tests:
+ print(test[0])
+ self.assertEqual(test[1].condition, test[2])
+
+ def test_register_none_func_raise_exception(self):
+ checker = checkers.Checker()
+ with self.assertRaises(checkers.RegisterError) as ctx:
+ checker.register('x', checkers.STD_NAMESPACE, None)
+ self.assertEqual(ctx.exception.args[0],
+ 'no check function registered for namespace std when '
+ 'registering condition x')
+
+ def test_register_no_registered_ns_exception(self):
+ checker = checkers.Checker()
+ with self.assertRaises(checkers.RegisterError) as ctx:
+ checker.register('x', 'testns', lambda x: None)
+ self.assertEqual(ctx.exception.args[0],
+ 'no prefix registered for namespace testns when '
+ 'registering condition x')
+
+ def test_register_empty_prefix_condition_with_colon(self):
+ checker = checkers.Checker()
+ checker.namespace().register('testns', '')
+ with self.assertRaises(checkers.RegisterError) as ctx:
+ checker.register('x:y', 'testns', lambda x: None)
+ self.assertEqual(ctx.exception.args[0],
+ 'caveat condition x:y in namespace testns contains a '
+ 'colon but its prefix is empty')
+
+ def test_register_twice_same_namespace(self):
+ checker = checkers.Checker()
+ checker.namespace().register('testns', '')
+ checker.register('x', 'testns', lambda x: None)
+ with self.assertRaises(checkers.RegisterError) as ctx:
+ checker.register('x', 'testns', lambda x: None)
+ self.assertEqual(ctx.exception.args[0],
+ 'checker for x (namespace testns) already registered'
+ ' in namespace testns')
+
+ def test_register_twice_different_namespace(self):
+ checker = checkers.Checker()
+ checker.namespace().register('testns', '')
+ checker.namespace().register('otherns', '')
+ checker.register('x', 'testns', lambda x: None)
+ with self.assertRaises(checkers.RegisterError) as ctx:
+ checker.register('x', 'otherns', lambda x: None)
+ self.assertEqual(ctx.exception.args[0],
+ 'checker for x (namespace otherns) already registered'
+ ' in namespace testns')
+
+ def test_checker_info(self):
+ checker = checkers.Checker(include_std_checkers=False)
+ checker.namespace().register('one', 't')
+ checker.namespace().register('two', 't')
+ checker.namespace().register('three', '')
+ checker.namespace().register('four', 's')
+
+ class Called(object):
+ val = ''
+
+ def register(name, ns):
+ def func(ctx, cond, arg):
+ Called.val = name + ' ' + ns
+ return None
+
+ checker.register(name, ns, func)
+
+ register('x', 'one')
+ register('y', 'one')
+ register('z', 'two')
+ register('a', 'two')
+ register('something', 'three')
+ register('other', 'three')
+ register('xxx', 'four')
+
+ expect = [
+ checkers.CheckerInfo(ns='four', name='xxx', prefix='s'),
+ checkers.CheckerInfo(ns='one', name='x', prefix='t'),
+ checkers.CheckerInfo(ns='one', name='y', prefix='t'),
+ checkers.CheckerInfo(ns='three', name='other', prefix=''),
+ checkers.CheckerInfo(ns='three', name='something', prefix=''),
+ checkers.CheckerInfo(ns='two', name='a', prefix='t'),
+ checkers.CheckerInfo(ns='two', name='z', prefix='t'),
+ ]
+ infos = checker.info()
+ self.assertEqual(len(infos), len(expect))
+ new_infos = []
+ for i, info in enumerate(infos):
+ Called.val = ''
+ info.check(None, '', '')
+ self.assertEqual(Called.val, expect[i].name + ' '
+ + expect[i].ns)
+ new_infos.append(checkers.CheckerInfo(ns=info.ns, name=info.name,
+ prefix=info.prefix))
+ self.assertEqual(new_infos, expect)
+
+
+def caveat_with_ns(cav, ns):
+ return checkers.Caveat(location=cav.location, condition=cav.condition,
+ namespace=ns)
+
+
+def arg_checker(test, expect_cond, check_arg):
+ ''' Returns a checker function that checks that the caveat condition is
+ check_arg.
+ '''
+
+ def func(ctx, cond, arg):
+ test.assertEqual(cond, expect_cond)
+ if arg != check_arg:
+ return 'wrong arg'
+ return None
+
+ return func
diff --git a/macaroonbakery/tests/test_codec.py b/macaroonbakery/tests/test_codec.py
index de1631c..6573266 100644
--- a/macaroonbakery/tests/test_codec.py
+++ b/macaroonbakery/tests/test_codec.py
@@ -1,95 +1,103 @@
# Copyright 2017 Canonical Ltd.
# Licensed under the LGPLv3, see LICENCE file for details.
+import base64
from unittest import TestCase
-import base64
+import nacl.public
import six
-import nacl.utils
-from nacl.public import PrivateKey
-from nacl.encoding import Base64Encoder
-
-from macaroonbakery import bakery, codec, macaroon, namespace, utils
+import macaroonbakery
+from macaroonbakery import utils
+from macaroonbakery import codec
+import macaroonbakery.checkers as checkers
class TestCodec(TestCase):
def setUp(self):
- self.fp_key = nacl.public.PrivateKey.generate()
- self.tp_key = nacl.public.PrivateKey.generate()
+ self.fp_key = macaroonbakery.generate_key()
+ self.tp_key = macaroonbakery.generate_key()
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(
+ tp_info = macaroonbakery.ThirdPartyInfo(
+ version=macaroonbakery.BAKERY_V1,
+ public_key=self.tp_key.public_key)
+ cid = macaroonbakery.encode_caveat(
+ 'is-authenticated-user',
+ b'a random string',
+ tp_info,
+ self.fp_key,
+ None)
+ res = macaroonbakery.decode_caveat(self.tp_key, cid)
+ self.assertEquals(res, macaroonbakery.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()
+ version=macaroonbakery.BAKERY_V1,
+ namespace=macaroonbakery.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(
+ tp_info = macaroonbakery.ThirdPartyInfo(
+ version=macaroonbakery.BAKERY_V2,
+ public_key=self.tp_key.public_key)
+ cid = macaroonbakery.encode_caveat(
+ 'is-authenticated-user',
+ b'a random string',
+ tp_info,
+ self.fp_key,
+ None)
+ res = macaroonbakery.decode_caveat(self.tp_key, cid)
+ self.assertEquals(res, macaroonbakery.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()
+ version=macaroonbakery.BAKERY_V2,
+ namespace=macaroonbakery.legacy_namespace()
))
def test_v3_round_trip(self):
- tp_info = bakery.ThirdPartyInfo(bakery.BAKERY_V3,
- self.tp_key.public_key)
- ns = namespace.Namespace()
+ tp_info = macaroonbakery.ThirdPartyInfo(
+ version=macaroonbakery.BAKERY_V3,
+ public_key=self.tp_key.public_key)
+ ns = checkers.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(
+ cid = macaroonbakery.encode_caveat(
+ 'is-authenticated-user',
+ b'a random string',
+ tp_info,
+ self.fp_key,
+ ns)
+ res = macaroonbakery.decode_caveat(self.tp_key, cid)
+ self.assertEquals(res, macaroonbakery.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
+ version=macaroonbakery.BAKERY_V3,
+ namespace=ns
))
def test_empty_caveat_id(self):
- with self.assertRaises(ValueError) as context:
- codec.decode_caveat(self.tp_key, b'')
+ with self.assertRaises(macaroonbakery.VerificationError) as context:
+ macaroonbakery.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)
+ tp_key = macaroonbakery.PrivateKey(
+ nacl.public.PrivateKey(base64.b64decode(
+ 'TSpvLpQkRj+T3JXnsW2n43n5zP/0X4zn0RvDiWC3IJ0=')))
+ fp_key = macaroonbakery.PrivateKey(
+ nacl.public.PrivateKey(base64.b64decode(
+ 'KXpsoJ9ujZYi/O2Cca6kaWh65MSawzy79LWkrjOfzcs=')))
+ root_key = base64.b64decode('vDxEmWZEkgiNEFlJ+8ruXe3qDSLf1H+o')
# This caveat has been generated from the go code
# to check the compatibilty
+
encrypted_cav = six.b(
'eyJUaGlyZFBhcnR5UHVibGljS2V5IjoiOFA3R1ZZc3BlWlN4c'
'3hFdmJsSVFFSTFqdTBTSWl0WlIrRFdhWE40cmxocz0iLCJGaX'
@@ -100,22 +108,25 @@ class TestCodec(TestCase):
'BORldUUExGdjVla1dWUjA4Uk1sbGJhc3c4VGdFbkhzM0laeVo'
'0V2lEOHhRUWdjU3ljOHY4eUt4dEhxejVEczJOYmh1ZDJhUFdt'
'UTVMcVlNWitmZ2FNaTAxdE9DIn0=')
- cav = codec.decode_caveat(tp_key, encrypted_cav)
- self.assertEquals(cav, macaroon.ThirdPartyCaveatInfo(
+ cav = macaroonbakery.decode_caveat(tp_key, encrypted_cav)
+ self.assertEquals(cav, macaroonbakery.ThirdPartyCaveatInfo(
condition='caveat condition',
first_party_public_key=fp_key.public_key,
third_party_key_pair=tp_key,
- root_key=b'random',
+ root_key=root_key,
caveat=encrypted_cav,
- version=bakery.BAKERY_V1,
- ns=macaroon.legacy_namespace()
+ version=macaroonbakery.BAKERY_V1,
+ namespace=macaroonbakery.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='))
+ tp_key = macaroonbakery.PrivateKey(nacl.public.PrivateKey(
+ base64.b64decode(
+ 'TSpvLpQkRj+T3JXnsW2n43n5zP/0X4zn0RvDiWC3IJ0=')))
+ fp_key = macaroonbakery.PrivateKey(
+ nacl.public.PrivateKey(base64.b64decode(
+ 'KXpsoJ9ujZYi/O2Cca6kaWh65MSawzy79LWkrjOfzcs=')))
+ root_key = base64.b64decode('wh0HSM65wWHOIxoGjgJJOFvQKn2jJFhC')
# This caveat has been generated from the go code
# to check the compatibilty
encrypted_cav = base64.urlsafe_b64decode(
@@ -123,22 +134,25 @@ class TestCodec(TestCase):
'AvD-xlUf2MdGMgtu7OKRQnCP1OQJk6PKeFWRK26WIBA6DNwKGIHq9xGcHS9IZ'
'Lh0cL6D9qpeKI0mXmCPfnwRQDuVYC8y5gVWd-oCGZaj5TGtk3byp2Vnw6ojmt'
'sULDhY59YA_J_Y0ATkERO5T9ajoRWBxU2OXBoX6bImXA')))
- cav = codec.decode_caveat(tp_key, encrypted_cav)
- self.assertEquals(cav, macaroon.ThirdPartyCaveatInfo(
+ cav = macaroonbakery.decode_caveat(tp_key, encrypted_cav)
+ self.assertEqual(cav, macaroonbakery.ThirdPartyCaveatInfo(
condition='third party condition',
first_party_public_key=fp_key.public_key,
third_party_key_pair=tp_key,
- root_key=b'random',
+ root_key=root_key,
caveat=encrypted_cav,
- version=bakery.BAKERY_V2,
- ns=macaroon.legacy_namespace()
+ version=macaroonbakery.BAKERY_V2,
+ namespace=macaroonbakery.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='))
+ tp_key = macaroonbakery.PrivateKey(
+ nacl.public.PrivateKey(base64.b64decode(
+ 'TSpvLpQkRj+T3JXnsW2n43n5zP/0X4zn0RvDiWC3IJ0=')))
+ fp_key = macaroonbakery.PrivateKey(nacl.public.PrivateKey(
+ base64.b64decode(
+ 'KXpsoJ9ujZYi/O2Cca6kaWh65MSawzy79LWkrjOfzcs=')))
+ root_key = base64.b64decode(b'oqOXI3/Mz/pKjCuFOt2eYxb7ndLq66GY')
# This caveat has been generated from the go code
# to check the compatibilty
encrypted_cav = base64.urlsafe_b64decode(
@@ -146,15 +160,15 @@ class TestCodec(TestCase):
'A_D-xlUf2MdGMgtu7OKRQnCP1OQJk6PKeFWRK26WIBA6DNwKGNLeFSkD2M-8A'
'EYvmgVH95GWu7T7caKxKhhOQFcEKgnXKJvYXxz1zin4cZc4Q6C7gVqA-J4_j3'
'1LX4VKxymqG62UGPo78wOv0_fKjr3OI6PPJOYOQgBMclemlRF2')))
- cav = codec.decode_caveat(tp_key, encrypted_cav)
- self.assertEquals(cav, macaroon.ThirdPartyCaveatInfo(
+ cav = macaroonbakery.decode_caveat(tp_key, encrypted_cav)
+ self.assertEquals(cav, macaroonbakery.ThirdPartyCaveatInfo(
condition='third party condition',
first_party_public_key=fp_key.public_key,
third_party_key_pair=tp_key,
- root_key=b'random',
+ root_key=root_key,
caveat=encrypted_cav,
- version=bakery.BAKERY_V3,
- ns=macaroon.legacy_namespace()
+ version=macaroonbakery.BAKERY_V3,
+ namespace=macaroonbakery.legacy_namespace()
))
def test_encode_decode_varint(self):
@@ -169,10 +183,10 @@ class TestCodec(TestCase):
for test in tests:
data = bytearray()
expected = bytearray()
- codec._encode_uvarint(test[0], data)
+ macaroonbakery.encode_uvarint(test[0], data)
for v in test[1]:
expected.append(v)
self.assertEquals(data, expected)
- val = codec._decode_uvarint(bytes(data))
+ 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_discharge.py b/macaroonbakery/tests/test_discharge.py
new file mode 100644
index 0000000..6e2df6a
--- /dev/null
+++ b/macaroonbakery/tests/test_discharge.py
@@ -0,0 +1,445 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+import unittest
+
+from pymacaroons import MACAROON_V1, Macaroon
+from pymacaroons.exceptions import (
+ MacaroonInvalidSignatureException, MacaroonUnmetCaveatException
+)
+
+import macaroonbakery
+import macaroonbakery.checkers as checkers
+from macaroonbakery.tests import common
+
+
+class TestDischarge(unittest.TestCase):
+ def test_single_service_first_party(self):
+ ''' Creates a single service with a macaroon with one first party
+ caveat.
+ It creates a request with this macaroon and checks that the service
+ can verify this macaroon as valid.
+ '''
+ oc = common.new_bakery('bakerytest')
+ primary = oc.oven.macaroon(macaroonbakery.LATEST_BAKERY_VERSION,
+ common.ages, None,
+ [macaroonbakery.LOGIN_OP])
+ self.assertEqual(primary.macaroon.location, 'bakerytest')
+ primary.add_caveat(checkers.Caveat(condition='str something',
+ namespace='testns'),
+ oc.oven.key, oc.oven.locator)
+ oc.checker.auth([[primary.macaroon]]).allow(
+ common.str_context('something'), [macaroonbakery.LOGIN_OP])
+
+ def test_macaroon_paper_fig6(self):
+ ''' Implements an example flow as described in the macaroons paper:
+ http://theory.stanford.edu/~ataly/Papers/macaroons.pdf
+ There are three services, ts, fs, bs:
+ ts is a store service which has deligated authority to a forum
+ service fs.
+ The forum service wants to require its users to be logged into to an
+ authentication service bs.
+
+ The client obtains a macaroon from fs (minted by ts, with a third party
+ caveat addressed to bs).
+ The client obtains a discharge macaroon from bs to satisfy this caveat.
+ The target service verifies the original macaroon it delegated to fs
+ No direct contact between bs and ts is required
+ '''
+ locator = macaroonbakery.ThirdPartyStore()
+ bs = common.new_bakery('bs-loc', locator)
+ ts = common.new_bakery('ts-loc', locator)
+ fs = common.new_bakery('fs-loc', locator)
+
+ # ts creates a macaroon.
+ ts_macaroon = ts.oven.macaroon(macaroonbakery.LATEST_BAKERY_VERSION,
+ common.ages,
+ None, [macaroonbakery.LOGIN_OP])
+
+ # ts somehow sends the macaroon to fs which adds a third party caveat
+ # to be discharged by bs.
+ ts_macaroon.add_caveat(checkers.Caveat(location='bs-loc',
+ condition='user==bob'),
+ fs.oven.key, fs.oven.locator)
+
+ # client asks for a discharge macaroon for each third party caveat
+ def get_discharge(ctx, cav, payload):
+ self.assertEqual(cav.location, 'bs-loc')
+ return macaroonbakery.discharge(ctx, cav.caveat_id_bytes, payload,
+ bs.oven.key,
+ common.ThirdPartyStrcmpChecker(
+ 'user==bob'),
+ bs.oven.locator)
+
+ d = macaroonbakery.discharge_all(common.test_context, ts_macaroon,
+ get_discharge)
+
+ ts.checker.auth([d]).allow(common.test_context,
+ [macaroonbakery.LOGIN_OP])
+
+ def test_discharge_with_version1_macaroon(self):
+ locator = macaroonbakery.ThirdPartyStore()
+ bs = common.new_bakery('bs-loc', locator)
+ ts = common.new_bakery('ts-loc', locator)
+
+ # ts creates a old-version macaroon.
+ ts_macaroon = ts.oven.macaroon(macaroonbakery.BAKERY_V1, common.ages,
+ None, [macaroonbakery.LOGIN_OP])
+ ts_macaroon.add_caveat(checkers.Caveat(condition='something',
+ location='bs-loc'),
+ ts.oven.key, ts.oven.locator)
+
+ # client asks for a discharge macaroon for each third party caveat
+
+ def get_discharge(ctx, cav, payload):
+ # Make sure that the caveat id really is old-style.
+ try:
+ cav.caveat_id_bytes.decode('utf-8')
+ except UnicodeDecodeError:
+ self.fail('caveat id is not utf-8')
+ return macaroonbakery.discharge(ctx, cav.caveat_id_bytes, payload,
+ bs.oven.key,
+ common.ThirdPartyStrcmpChecker(
+ 'something'),
+ bs.oven.locator)
+
+ d = macaroonbakery.discharge_all(common.test_context, ts_macaroon,
+ get_discharge)
+
+ ts.checker.auth([d]).allow(common.test_context,
+ [macaroonbakery.LOGIN_OP])
+
+ for m in d:
+ self.assertEqual(m.version, MACAROON_V1)
+
+ def test_version1_macaroon_id(self):
+ # In the version 1 bakery, macaroon ids were hex-encoded with a
+ # hyphenated UUID suffix.
+ root_key_store = macaroonbakery.MemoryKeyStore()
+ b = macaroonbakery.Bakery(root_key_store=root_key_store,
+ identity_client=common.OneIdentity())
+ key, id = root_key_store.root_key()
+ root_key_store.get(id)
+ m = Macaroon(key=key, version=MACAROON_V1, location='',
+ identifier=id + b'-deadl00f')
+ b.checker.auth([[m]]).allow(common.test_context,
+ [macaroonbakery.LOGIN_OP])
+
+ def test_macaroon_paper_fig6_fails_without_discharges(self):
+ ''' Runs a similar test as test_macaroon_paper_fig6 without the client
+ discharging the third party caveats.
+ '''
+ locator = macaroonbakery.ThirdPartyStore()
+ ts = common.new_bakery('ts-loc', locator)
+ fs = common.new_bakery('fs-loc', locator)
+ common.new_bakery('as-loc', locator)
+
+ # ts creates a macaroon.
+ ts_macaroon = ts.oven.macaroon(macaroonbakery.LATEST_BAKERY_VERSION,
+ common.ages, None,
+ [macaroonbakery.LOGIN_OP])
+
+ # ts somehow sends the macaroon to fs which adds a third party
+ # caveat to be discharged by as.
+ ts_macaroon.add_caveat(checkers.Caveat(location='as-loc',
+ condition='user==bob'),
+ fs.oven.key, fs.oven.locator)
+
+ # client makes request to ts
+ try:
+ ts.checker.auth([[ts_macaroon.macaroon]]).allow(
+ common.test_context,
+ macaroonbakery.LOGIN_OP
+ )
+ self.fail('macaroon unmet should be raised')
+ except MacaroonUnmetCaveatException:
+ pass
+
+ def test_macaroon_paper_fig6_fails_with_binding_on_tampered_sig(self):
+ ''' Runs a similar test as test_macaroon_paper_fig6 with the discharge
+ macaroon binding being done on a tampered signature.
+ '''
+ locator = macaroonbakery.ThirdPartyStore()
+ bs = common.new_bakery('bs-loc', locator)
+ ts = common.new_bakery('ts-loc', locator)
+
+ # ts creates a macaroon.
+ ts_macaroon = ts.oven.macaroon(macaroonbakery.LATEST_BAKERY_VERSION,
+ common.ages, None,
+ [macaroonbakery.LOGIN_OP])
+ # ts somehow sends the macaroon to fs which adds a third party caveat
+ # to be discharged by as.
+ ts_macaroon.add_caveat(checkers.Caveat(condition='user==bob',
+ location='bs-loc'),
+ ts.oven.key, ts.oven.locator)
+
+ # client asks for a discharge macaroon for each third party caveat
+ def get_discharge(ctx, cav, payload):
+ self.assertEqual(cav.location, 'bs-loc')
+ return macaroonbakery.discharge(ctx, cav.caveat_id_bytes, payload,
+ bs.oven.key,
+ common.ThirdPartyStrcmpChecker(
+ 'user==bob'),
+ bs.oven.locator)
+
+ d = macaroonbakery.discharge_all(common.test_context, ts_macaroon,
+ get_discharge)
+ # client has all the discharge macaroons. For each discharge macaroon
+ # bind it to our ts_macaroon and add it to our request.
+ tampered_macaroon = Macaroon()
+ for i, dm in enumerate(d[1:]):
+ d[i + 1] = tampered_macaroon.prepare_for_request(dm)
+
+ # client makes request to ts.
+ with self.assertRaises(MacaroonInvalidSignatureException) as exc:
+ ts.checker.auth([d]).allow(common.test_context,
+ macaroonbakery.LOGIN_OP)
+ self.assertEqual('Signatures do not match', exc.exception.args[0])
+
+ def test_need_declared(self):
+ locator = macaroonbakery.ThirdPartyStore()
+ first_party = common.new_bakery('first', locator)
+ third_party = common.new_bakery('third', locator)
+
+ # firstParty mints a macaroon with a third-party caveat addressed
+ # to thirdParty with a need-declared caveat.
+ m = first_party.oven.macaroon(
+ macaroonbakery.LATEST_BAKERY_VERSION, common.ages, [
+ checkers.need_declared_caveat(
+ checkers.Caveat(location='third', condition='something'),
+ ['foo', 'bar']
+ )
+ ], [macaroonbakery.LOGIN_OP])
+
+ # The client asks for a discharge macaroon for each third party caveat.
+ def get_discharge(ctx, cav, payload):
+ return macaroonbakery.discharge(ctx, cav.caveat_id_bytes, payload,
+ third_party.oven.key,
+ common.ThirdPartyStrcmpChecker(
+ 'something'),
+ third_party.oven.locator)
+
+ d = macaroonbakery.discharge_all(common.test_context, m, get_discharge)
+
+ # The required declared attributes should have been added
+ # to the discharge macaroons.
+ declared = checkers.infer_declared(d, first_party.checker.namespace())
+ self.assertEqual(declared, {
+ 'foo': '',
+ 'bar': '',
+ })
+
+ # Make sure the macaroons actually check out correctly
+ # when provided with the declared checker.
+ ctx = checkers.context_with_declared(common.test_context, declared)
+ first_party.checker.auth([d]).allow(ctx, [macaroonbakery.LOGIN_OP])
+
+ # Try again when the third party does add a required declaration.
+
+ # The client asks for a discharge macaroon for each third party caveat.
+ def get_discharge(ctx, cav, payload):
+ checker = common.ThirdPartyCheckerWithCaveats([
+ checkers.declared_caveat('foo', 'a'),
+ checkers.declared_caveat('arble', 'b')
+ ])
+ return macaroonbakery.discharge(ctx, cav.caveat_id_bytes, payload,
+ third_party.oven.key,
+ checker,
+ third_party.oven.locator)
+
+ d = macaroonbakery.discharge_all(common.test_context, m, get_discharge)
+
+ # One attribute should have been added, the other was already there.
+ declared = checkers.infer_declared(d, first_party.checker.namespace())
+ self.assertEqual(declared, {
+ 'foo': 'a',
+ 'bar': '',
+ 'arble': 'b',
+ })
+
+ ctx = checkers.context_with_declared(common.test_context, declared)
+ first_party.checker.auth([d]).allow(ctx, [macaroonbakery.LOGIN_OP])
+
+ # Try again, but this time pretend a client is sneakily trying
+ # to add another 'declared' attribute to alter the declarations.
+
+ def get_discharge(ctx, cav, payload):
+ checker = common.ThirdPartyCheckerWithCaveats([
+ checkers.declared_caveat('foo', 'a'),
+ checkers.declared_caveat('arble', 'b'),
+ ])
+
+ # Sneaky client adds a first party caveat.
+ m = macaroonbakery.discharge(ctx, cav.caveat_id_bytes, payload,
+ third_party.oven.key, checker,
+ third_party.oven.locator)
+ m.add_caveat(checkers.declared_caveat('foo', 'c'), None, None)
+ return m
+
+ d = macaroonbakery.discharge_all(common.test_context, m, get_discharge)
+
+ declared = checkers.infer_declared(d, first_party.checker.namespace())
+ self.assertEqual(declared, {
+ 'bar': '',
+ 'arble': 'b',
+ })
+
+ with self.assertRaises(macaroonbakery.AuthInitError) as exc:
+ first_party.checker.auth([d]).allow(common.test_context,
+ macaroonbakery.LOGIN_OP)
+ self.assertEqual('cannot authorize login macaroon: caveat '
+ '"declared foo a" not satisfied: got foo=null, '
+ 'expected "a"', exc.exception.args[0])
+
+ def test_discharge_two_need_declared(self):
+ locator = macaroonbakery.ThirdPartyStore()
+ first_party = common.new_bakery('first', locator)
+ third_party = common.new_bakery('third', locator)
+
+ # first_party mints a macaroon with two third party caveats
+ # with overlapping attributes.
+ m = first_party.oven.macaroon(
+ macaroonbakery.LATEST_BAKERY_VERSION,
+ common.ages, [
+ checkers.need_declared_caveat(
+ checkers.Caveat(location='third', condition='x'),
+ ['foo', 'bar']),
+ checkers.need_declared_caveat(
+ checkers.Caveat(location='third', condition='y'),
+ ['bar', 'baz']),
+ ], [macaroonbakery.LOGIN_OP])
+
+ # The client asks for a discharge macaroon for each third party caveat.
+ # Since no declarations are added by the discharger,
+
+ def get_discharge(ctx, cav, payload):
+ return macaroonbakery.discharge(
+ ctx, cav.caveat_id_bytes, payload, third_party.oven.key,
+ common.ThirdPartyCaveatCheckerEmpty(),
+ third_party.oven.locator)
+
+ d = macaroonbakery.discharge_all(common.test_context, m, get_discharge)
+ declared = checkers.infer_declared(d, first_party.checker.namespace())
+ self.assertEqual(declared, {
+ 'foo': '',
+ 'bar': '',
+ 'baz': '',
+ })
+ ctx = checkers.context_with_declared(common.test_context, declared)
+ first_party.checker.auth([d]).allow(ctx, [macaroonbakery.LOGIN_OP])
+
+ # If they return conflicting values, the discharge fails.
+ # The client asks for a discharge macaroon for each third party caveat.
+ # Since no declarations are added by the discharger,
+ class ThirdPartyCaveatCheckerF(macaroonbakery.ThirdPartyCaveatChecker):
+ def check_third_party_caveat(self, ctx, cav_info):
+ if cav_info.condition == b'x':
+ return [checkers.declared_caveat('foo', 'fooval1')]
+ if cav_info.condition == b'y':
+ return [
+ checkers.declared_caveat('foo', 'fooval2'),
+ checkers.declared_caveat('baz', 'bazval')
+ ]
+ raise common.ThirdPartyCaveatCheckFailed('not matched')
+
+ def get_discharge(ctx, cav, payload):
+ return macaroonbakery.discharge(ctx, cav.caveat_id_bytes, payload,
+ third_party.oven.key,
+ ThirdPartyCaveatCheckerF(),
+ third_party.oven.locator)
+
+ d = macaroonbakery.discharge_all(common.test_context, m, get_discharge)
+
+ declared = checkers.infer_declared(d, first_party.checker.namespace())
+ self.assertEqual(declared, {
+ 'bar': '',
+ 'baz': 'bazval',
+ })
+ with self.assertRaises(macaroonbakery.AuthInitError) as exc:
+ first_party.checker.auth([d]).allow(common.test_context,
+ macaroonbakery.LOGIN_OP)
+ self.assertEqual('cannot authorize login macaroon: caveat "declared '
+ 'foo fooval1" not satisfied: got foo=null, expected '
+ '"fooval1"', exc.exception.args[0])
+
+ def test_discharge_macaroon_cannot_be_used_as_normal_macaroon(self):
+ locator = macaroonbakery.ThirdPartyStore()
+ first_party = common.new_bakery('first', locator)
+ third_party = common.new_bakery('third', locator)
+
+ # First party mints a macaroon with a 3rd party caveat.
+ m = first_party.oven.macaroon(macaroonbakery.LATEST_BAKERY_VERSION,
+ common.ages, [
+ checkers.Caveat(location='third',
+ condition='true')],
+ [macaroonbakery.LOGIN_OP])
+
+ # Acquire the discharge macaroon, but don't bind it to the original.
+ class M:
+ unbound = None
+
+ def get_discharge(ctx, cav, payload):
+ m = macaroonbakery.discharge(
+ ctx, cav.caveat_id_bytes, payload, third_party.oven.key,
+ common.ThirdPartyStrcmpChecker('true'),
+ third_party.oven.locator)
+ M.unbound = m.macaroon.copy()
+ return m
+
+ macaroonbakery.discharge_all(common.test_context, m, get_discharge)
+ self.assertIsNotNone(M.unbound)
+
+ # Make sure it cannot be used as a normal macaroon in the third party.
+ with self.assertRaises(macaroonbakery.AuthInitError) as exc:
+ third_party.checker.auth([[M.unbound]]).allow(
+ common.test_context, [macaroonbakery.LOGIN_OP])
+ self.assertEqual('no operations found in macaroon',
+ exc.exception.args[0])
+
+ def test_third_party_discharge_macaroon_ids_are_small(self):
+ locator = macaroonbakery.ThirdPartyStore()
+ bakeries = {
+ 'ts-loc': common.new_bakery('ts-loc', locator),
+ 'as1-loc': common.new_bakery('as1-loc', locator),
+ 'as2-loc': common.new_bakery('as2-loc', locator),
+ }
+ ts = bakeries['ts-loc']
+
+ ts_macaroon = ts.oven.macaroon(macaroonbakery.LATEST_BAKERY_VERSION,
+ common.ages,
+ None, [macaroonbakery.LOGIN_OP])
+ ts_macaroon.add_caveat(checkers.Caveat(condition='something',
+ location='as1-loc'),
+ ts.oven.key, ts.oven.locator)
+
+ class ThirdPartyCaveatCheckerF(macaroonbakery.ThirdPartyCaveatChecker):
+ def __init__(self, loc):
+ self._loc = loc
+
+ def check_third_party_caveat(self, ctx, info):
+ if self._loc == 'as1-loc':
+ return [checkers.Caveat(condition='something',
+ location='as2-loc')]
+ if self._loc == 'as2-loc':
+ return []
+ raise common.ThirdPartyCaveatCheckFailed(
+ 'unknown location {}'.format(self._loc))
+
+ def get_discharge(ctx, cav, payload):
+ oven = bakeries[cav.location].oven
+ return macaroonbakery.discharge(ctx, cav.caveat_id_bytes, payload,
+ oven.key,
+ ThirdPartyCaveatCheckerF(
+ cav.location),
+ oven.locator)
+
+ d = macaroonbakery.discharge_all(common.test_context, ts_macaroon,
+ get_discharge)
+ ts.checker.auth([d]).allow(common.test_context,
+ [macaroonbakery.LOGIN_OP])
+
+ for i, m in enumerate(d):
+ for j, cav in enumerate(m.caveats):
+ if (cav.verification_key_id is not None and
+ len(cav.caveat_id) > 3):
+ self.fail('caveat id on caveat {} of macaroon {} '
+ 'is too big ({})'.format(j, i, cav.id))
diff --git a/macaroonbakery/tests/test_discharge_all.py b/macaroonbakery/tests/test_discharge_all.py
new file mode 100644
index 0000000..8da8823
--- /dev/null
+++ b/macaroonbakery/tests/test_discharge_all.py
@@ -0,0 +1,170 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+import unittest
+
+from pymacaroons.verifier import Verifier
+
+import macaroonbakery
+import macaroonbakery.checkers as checkers
+from macaroonbakery.tests import common
+
+
+def always_ok(predicate):
+ return True
+
+
+class TestDischargeAll(unittest.TestCase):
+ def test_discharge_all_no_discharges(self):
+ root_key = b'root key'
+ m = macaroonbakery.Macaroon(
+ root_key=root_key, id=b'id0', location='loc0',
+ version=macaroonbakery.LATEST_BAKERY_VERSION,
+ namespace=common.test_checker().namespace())
+ ms = macaroonbakery.discharge_all(
+ common.test_context, m, no_discharge(self))
+ self.assertEqual(len(ms), 1)
+ self.assertEqual(ms[0], m.macaroon)
+ v = Verifier()
+ v.satisfy_general(always_ok)
+ v.verify(m.macaroon, root_key, None)
+
+ def test_discharge_all_many_discharges(self):
+ root_key = b'root key'
+ m0 = macaroonbakery.Macaroon(
+ root_key=root_key, id=b'id0', location='loc0',
+ version=macaroonbakery.LATEST_BAKERY_VERSION)
+
+ class State(object):
+ total_required = 40
+ id = 1
+
+ def add_caveats(m):
+ for i in range(0, 1):
+ if State.total_required == 0:
+ break
+ cid = 'id{}'.format(State.id)
+ m.macaroon.add_third_party_caveat(
+ location='somewhere',
+ key='root key {}'.format(cid).encode('utf-8'),
+ key_id=cid.encode('utf-8'))
+ State.id += 1
+ State.total_required -= 1
+
+ add_caveats(m0)
+
+ def get_discharge(_, cav, payload):
+ self.assertEqual(payload, None)
+ m = macaroonbakery.Macaroon(
+ root_key='root key {}'.format(
+ cav.caveat_id.decode('utf-8')).encode('utf-8'),
+ id=cav.caveat_id, location='',
+ version=macaroonbakery.LATEST_BAKERY_VERSION)
+
+ add_caveats(m)
+ return m
+
+ ms = macaroonbakery.discharge_all(
+ common.test_context, m0, get_discharge)
+
+ self.assertEqual(len(ms), 41)
+
+ v = Verifier()
+ v.satisfy_general(always_ok)
+ v.verify(ms[0], root_key, ms[1:])
+
+ def test_discharge_all_many_discharges_with_real_third_party_caveats(self):
+ # This is the same flow as TestDischargeAllManyDischarges except that
+ # we're using actual third party caveats as added by
+ # Macaroon.add_caveat and we use a larger number of caveats
+ # so that caveat ids will need to get larger.
+ locator = macaroonbakery.ThirdPartyStore()
+ bakeries = {}
+ total_discharges_required = 40
+
+ class M:
+ bakery_id = 0
+ still_required = total_discharges_required
+
+ def add_bakery():
+ M.bakery_id += 1
+ loc = 'loc{}'.format(M.bakery_id)
+ bakeries[loc] = common.new_bakery(loc, locator)
+ return loc
+
+ ts = common.new_bakery('ts-loc', locator)
+
+ def checker(_, ci):
+ caveats = []
+ if ci.condition != 'something':
+ self.fail('unexpected condition')
+ for i in range(0, 2):
+ if M.still_required <= 0:
+ break
+ caveats.append(checkers.Caveat(location=add_bakery(),
+ condition='something'))
+ M.still_required -= 1
+ return caveats
+
+ root_key = b'root key'
+ m0 = macaroonbakery.Macaroon(
+ root_key=root_key, id=b'id0', location='ts-loc',
+ version=macaroonbakery.LATEST_BAKERY_VERSION)
+
+ m0.add_caveat(checkers. Caveat(location=add_bakery(),
+ condition='something'),
+ ts.oven.key, locator)
+
+ # We've added a caveat (the first) so one less caveat is required.
+ M.still_required -= 1
+
+ class ThirdPartyCaveatCheckerF(macaroonbakery.ThirdPartyCaveatChecker):
+ def check_third_party_caveat(self, ctx, info):
+ return checker(ctx, info)
+
+ def get_discharge(ctx, cav, payload):
+ return macaroonbakery.discharge(
+ ctx, cav.caveat_id, payload,
+ bakeries[cav.location].oven.key,
+ ThirdPartyCaveatCheckerF(), locator)
+
+ ms = macaroonbakery.discharge_all(common.test_context, m0,
+ get_discharge)
+
+ self.assertEqual(len(ms), total_discharges_required + 1)
+
+ v = Verifier()
+ v.satisfy_general(always_ok)
+ v.verify(ms[0], root_key, ms[1:])
+
+ def test_discharge_all_local_discharge(self):
+ oc = common.new_bakery('ts', None)
+ client_key = macaroonbakery.generate_key()
+ m = oc.oven.macaroon(macaroonbakery.LATEST_BAKERY_VERSION, common.ages,
+ [
+ macaroonbakery.local_third_party_caveat(
+ client_key.public_key,
+ macaroonbakery.LATEST_BAKERY_VERSION)
+ ], [macaroonbakery.LOGIN_OP])
+ ms = macaroonbakery.discharge_all(
+ common.test_context, m, no_discharge(self), client_key)
+ oc.checker.auth([ms]).allow(common.test_context,
+ [macaroonbakery.LOGIN_OP])
+
+ def test_discharge_all_local_discharge_version1(self):
+ oc = common.new_bakery('ts', None)
+ client_key = macaroonbakery.generate_key()
+ m = oc.oven.macaroon(macaroonbakery.BAKERY_V1, common.ages, [
+ macaroonbakery.local_third_party_caveat(
+ client_key.public_key, macaroonbakery.BAKERY_V1)
+ ], [macaroonbakery.LOGIN_OP])
+ ms = macaroonbakery.discharge_all(
+ common.test_context, m, no_discharge(self), client_key)
+ oc.checker.auth([ms]).allow(common.test_context,
+ [macaroonbakery.LOGIN_OP])
+
+
+def no_discharge(test):
+ def get_discharge(ctx, cav, payload):
+ test.fail("get_discharge called unexpectedly")
+
+ return get_discharge
diff --git a/macaroonbakery/tests/test_keyring.py b/macaroonbakery/tests/test_keyring.py
new file mode 100644
index 0000000..351b144
--- /dev/null
+++ b/macaroonbakery/tests/test_keyring.py
@@ -0,0 +1,111 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+import unittest
+
+from httmock import urlmatch, HTTMock
+
+import macaroonbakery
+from macaroonbakery import httpbakery
+
+
+class TestKeyRing(unittest.TestCase):
+
+ def test_cache_fetch(self):
+ key = macaroonbakery.generate_key()
+
+ @urlmatch(path='.*/discharge/info')
+ def discharge_info(url, request):
+ return {
+ 'status_code': 200,
+ 'content': {
+ 'Version': macaroonbakery.LATEST_BAKERY_VERSION,
+ 'PublicKey': key.public_key.encode().decode('utf-8')
+ }
+ }
+
+ expectInfo = macaroonbakery.ThirdPartyInfo(
+ public_key=key.public_key,
+ version=macaroonbakery.LATEST_BAKERY_VERSION
+ )
+ kr = httpbakery.ThirdPartyLocator(allow_insecure=True)
+ with HTTMock(discharge_info):
+ info = kr.third_party_info('http://0.1.2.3/')
+ self.assertEqual(info, expectInfo)
+
+ def test_cache_norefetch(self):
+ key = macaroonbakery.generate_key()
+
+ @urlmatch(path='.*/discharge/info')
+ def discharge_info(url, request):
+ return {
+ 'status_code': 200,
+ 'content': {
+ 'Version': macaroonbakery.LATEST_BAKERY_VERSION,
+ 'PublicKey': key.public_key.encode().decode('utf-8')
+ }
+ }
+
+ expectInfo = macaroonbakery.ThirdPartyInfo(
+ public_key=key.public_key,
+ version=macaroonbakery.LATEST_BAKERY_VERSION
+ )
+ kr = httpbakery.ThirdPartyLocator(allow_insecure=True)
+ with HTTMock(discharge_info):
+ info = kr.third_party_info('http://0.1.2.3/')
+ self.assertEqual(info, expectInfo)
+ info = kr.third_party_info('http://0.1.2.3/')
+ self.assertEqual(info, expectInfo)
+
+ def test_cache_fetch_no_version(self):
+ key = macaroonbakery.generate_key()
+
+ @urlmatch(path='.*/discharge/info')
+ def discharge_info(url, request):
+ return {
+ 'status_code': 200,
+ 'content': {
+ 'PublicKey': key.public_key.encode().decode('utf-8')
+ }
+ }
+
+ expectInfo = macaroonbakery.ThirdPartyInfo(
+ public_key=key.public_key,
+ version=macaroonbakery.BAKERY_V1
+ )
+ kr = httpbakery.ThirdPartyLocator(allow_insecure=True)
+ with HTTMock(discharge_info):
+ info = kr.third_party_info('http://0.1.2.3/')
+ self.assertEqual(info, expectInfo)
+
+ def test_allow_insecure(self):
+ kr = httpbakery.ThirdPartyLocator()
+ with self.assertRaises(macaroonbakery.error.ThirdPartyInfoNotFound):
+ kr.third_party_info('http://0.1.2.3/')
+
+ def test_fallback(self):
+ key = macaroonbakery.generate_key()
+
+ @urlmatch(path='.*/discharge/info')
+ def discharge_info(url, request):
+ return {
+ 'status_code': 404,
+ }
+
+ @urlmatch(path='.*/publickey')
+ def public_key(url, request):
+ return {
+ 'status_code': 200,
+ 'content': {
+ 'PublicKey': key.public_key.encode().decode('utf-8')
+ }
+ }
+
+ expectInfo = macaroonbakery.ThirdPartyInfo(
+ public_key=key.public_key,
+ version=macaroonbakery.BAKERY_V1
+ )
+ kr = httpbakery.ThirdPartyLocator(allow_insecure=True)
+ with HTTMock(discharge_info):
+ with HTTMock(public_key):
+ info = kr.third_party_info('http://0.1.2.3/')
+ self.assertEqual(info, expectInfo)
diff --git a/macaroonbakery/tests/test_macaroon.py b/macaroonbakery/tests/test_macaroon.py
index afc7d52..7e77e2b 100644
--- a/macaroonbakery/tests/test_macaroon.py
+++ b/macaroonbakery/tests/test_macaroon.py
@@ -1,64 +1,202 @@
# Copyright 2017 Canonical Ltd.
# Licensed under the LGPLv3, see LICENCE file for details.
-
+import json
from unittest import TestCase
import six
+import pymacaroons
+from pymacaroons import serializers
-import nacl.utils
-
-from macaroonbakery import bakery, macaroon, checkers, codec
+import macaroonbakery
+import macaroonbakery.checkers as checkers
+from macaroonbakery.tests import common
class TestMacaroon(TestCase):
def test_new_macaroon(self):
- m = macaroon.Macaroon(b'rootkey',
- b'some id',
- 'here',
- bakery.LATEST_BAKERY_VERSION)
+ m = macaroonbakery.Macaroon(
+ b'rootkey',
+ b'some id',
+ 'here',
+ macaroonbakery.LATEST_BAKERY_VERSION)
self.assertIsNotNone(m)
- self.assertEquals(m._macaroon.identifier, 'some id')
+ self.assertEquals(m._macaroon.identifier, b'some id')
self.assertEquals(m._macaroon.location, 'here')
- self.assertEquals(m.version, macaroon.macaroon_version(
- bakery.LATEST_BAKERY_VERSION))
+ self.assertEquals(m.version, macaroonbakery.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'))
+ m = macaroonbakery.Macaroon('rootkey', 'some id', 'here',
+ macaroonbakery.LATEST_BAKERY_VERSION)
+ 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')
+ self.assertEquals(caveats[0].caveat_id, b'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()
- ))
+ locator = macaroonbakery.ThirdPartyStore()
+ bs = common.new_bakery('bs-loc', locator)
+
+ lbv = six.int2byte(macaroonbakery.LATEST_BAKERY_VERSION)
+ tests = [
+ ('no existing id', b'', [], lbv + six.int2byte(0)),
+ ('several existing ids', b'', [
+ lbv + six.int2byte(0),
+ lbv + six.int2byte(1),
+ lbv + six.int2byte(2)
+ ], lbv + six.int2byte(3)),
+ ('with base id', lbv + six.int2byte(0), [lbv + six.int2byte(0)],
+ lbv + six.int2byte(0) + six.int2byte(0)),
+ ('with base id and existing id', lbv + six.int2byte(0), [
+ lbv + six.int2byte(0) + six.int2byte(0)
+ ], lbv + six.int2byte(0) + six.int2byte(1))
+ ]
+
+ for test in tests:
+ print('test ', test[0])
+ m = macaroonbakery.Macaroon(
+ root_key=b'root key', id=b'id',
+ location='location',
+ version=macaroonbakery.LATEST_BAKERY_VERSION)
+ for id in test[2]:
+ m.macaroon.add_third_party_caveat(key=None, key_id=id,
+ location='')
+ m._caveat_id_prefix = test[1]
+ m.add_caveat(checkers.Caveat(location='bs-loc',
+ condition='something'),
+ bs.oven.key, locator)
+ self.assertEqual(m.macaroon.caveats[len(test[2])].caveat_id,
+ test[3])
+
+ def test_marshal_json_latest_version(self):
+ locator = macaroonbakery.ThirdPartyStore()
+ bs = common.new_bakery('bs-loc', locator)
+ ns = checkers.Namespace({
+ 'testns': 'x',
+ 'otherns': 'y',
+ })
+ m = macaroonbakery.Macaroon(
+ root_key=b'root key', id=b'id',
+ location='location',
+ version=macaroonbakery.LATEST_BAKERY_VERSION,
+ namespace=ns)
+ m.add_caveat(checkers.Caveat(location='bs-loc', condition='something'),
+ bs.oven.key, locator)
+ data = m.serialize_json()
+ m1 = macaroonbakery.Macaroon.deserialize_json(data)
+ # Just check the signature and version - we're not interested in fully
+ # checking the macaroon marshaling here.
+ self.assertEqual(m1.macaroon.signature, m.macaroon.signature)
+ self.assertEqual(m1.macaroon.version, m.macaroon.version)
+ self.assertEqual(len(m1.macaroon.caveats), 1)
+ self.assertEqual(m1.namespace, m.namespace)
+ self.assertEqual(m1._caveat_data, m._caveat_data)
+
+ # test with the encoder, decoder
+ data = json.dumps(m, cls=macaroonbakery.MacaroonJSONEncoder)
+ m1 = json.loads(data, cls=macaroonbakery.MacaroonJSONDecoder)
+ self.assertEqual(m1.macaroon.signature, m.macaroon.signature)
+ self.assertEqual(m1.macaroon.version, m.macaroon.version)
+ self.assertEqual(len(m1.macaroon.caveats), 1)
+ self.assertEqual(m1.namespace, m.namespace)
+ self.assertEqual(m1._caveat_data, m._caveat_data)
+
+ def test_json_version1(self):
+ self._test_json_with_version(macaroonbakery.BAKERY_V1)
+
+ def test_json_version2(self):
+ self._test_json_with_version(macaroonbakery.BAKERY_V2)
+
+ def _test_json_with_version(self, version):
+ locator = macaroonbakery.ThirdPartyStore()
+ bs = common.new_bakery('bs-loc', locator)
+
+ ns = checkers.Namespace({
+ 'testns': 'x',
+ })
+
+ m = macaroonbakery.Macaroon(
+ root_key=b'root key', id=b'id',
+ location='location', version=version,
+ namespace=ns)
+ m.add_caveat(checkers.Caveat(location='bs-loc', condition='something'),
+ bs.oven.key, locator)
+
+ # Sanity check that no external caveat data has been added.
+ self.assertEqual(len(m._caveat_data), 0)
+
+ data = json.dumps(m, cls=macaroonbakery.MacaroonJSONEncoder)
+ m1 = json.loads(data, cls=macaroonbakery.MacaroonJSONDecoder)
+
+ # Just check the signature and version - we're not interested in fully
+ # checking the macaroon marshaling here.
+ self.assertEqual(m1.macaroon.signature, m.macaroon.signature)
+ self.assertEqual(m1.macaroon.version,
+ macaroonbakery.macaroon_version(version))
+ self.assertEqual(len(m1.macaroon.caveats), 1)
+
+ # Namespace information has been thrown away.
+ self.assertEqual(m1.namespace, macaroonbakery.legacy_namespace())
+
+ self.assertEqual(len(m1._caveat_data), 0)
+
+ def test_json_unknown_version(self):
+ m = pymacaroons.Macaroon(version=pymacaroons.MACAROON_V2)
+ with self.assertRaises(ValueError) as exc:
+ json.loads(json.dumps({
+ 'm': m.serialize(serializer=serializers.JsonSerializer()),
+ 'v': macaroonbakery.LATEST_BAKERY_VERSION + 1
+ }), cls=macaroonbakery.MacaroonJSONDecoder)
+ self.assertEqual('unknow bakery version 4', exc.exception.args[0])
+
+ def test_json_inconsistent_version(self):
+ m = pymacaroons.Macaroon(version=pymacaroons.MACAROON_V1)
+ with self.assertRaises(ValueError) as exc:
+ json.loads(json.dumps({
+ 'm': json.loads(m.serialize(
+ serializer=serializers.JsonSerializer())),
+ 'v': macaroonbakery.LATEST_BAKERY_VERSION
+ }), cls=macaroonbakery.MacaroonJSONDecoder)
+ self.assertEqual('underlying macaroon has inconsistent version; '
+ 'got 1 want 2', exc.exception.args[0])
+
+ def test_clone(self):
+ locator = macaroonbakery.ThirdPartyStore()
+ bs = common.new_bakery("bs-loc", locator)
+ ns = checkers.Namespace({
+ "testns": "x",
+ })
+ m = macaroonbakery.Macaroon(
+ root_key=b'root key', id=b'id',
+ location='location',
+ version=macaroonbakery.LATEST_BAKERY_VERSION,
+ namespace=ns)
+ m.add_caveat(checkers.Caveat(location='bs-loc', condition='something'),
+ bs.oven.key, locator)
+ m1 = m.copy()
+ self.assertEqual(len(m.macaroon.caveats), 1)
+ self.assertEqual(len(m1.macaroon.caveats), 1)
+ self.assertEqual(m._caveat_data, m1._caveat_data)
+ m.add_caveat(checkers.Caveat(location='bs-loc', condition='something'),
+ bs.oven.key, locator)
+ self.assertEqual(len(m.macaroon.caveats), 2)
+ self.assertEqual(len(m1.macaroon.caveats), 1)
+ self.assertNotEqual(m._caveat_data, m1._caveat_data)
+
+ def test_json_deserialize_from_go(self):
+ ns = checkers.Namespace()
+ ns.register("someuri", "x")
+ m = macaroonbakery.Macaroon(
+ root_key=b'rootkey', id=b'some id', location='here',
+ version=macaroonbakery.LATEST_BAKERY_VERSION, namespace=ns)
+ m.add_caveat(checkers.Caveat(condition='something',
+ namespace='someuri'))
+ data = '{"m":{"c":[{"i":"x:something"}],"l":"here","i":"some id",' \
+ '"s64":"c8edRIupArSrY-WZfa62pgZFD8VjDgqho9U2PlADe-E"},"v":3,' \
+ '"ns":"someuri:x"}'
+ m_go = macaroonbakery.Macaroon.deserialize_json(data)
+
+ self.assertEqual(m.macaroon.signature_bytes,
+ m_go.macaroon.signature_bytes)
+ self.assertEqual(m.macaroon.version, m_go.macaroon.version)
+ self.assertEqual(len(m_go.macaroon.caveats), 1)
+ self.assertEqual(m.namespace, m_go.namespace)
diff --git a/macaroonbakery/tests/test_namespace.py b/macaroonbakery/tests/test_namespace.py
index 24eda29..2f04bb3 100644
--- a/macaroonbakery/tests/test_namespace.py
+++ b/macaroonbakery/tests/test_namespace.py
@@ -1,9 +1,8 @@
# Copyright 2017 Canonical Ltd.
# Licensed under the LGPLv3, see LICENCE file for details.
-
from unittest import TestCase
-from macaroonbakery import namespace
+import macaroonbakery.checkers as checkers
class TestNamespace(TestCase):
@@ -23,17 +22,17 @@ class TestNamespace(TestCase):
}, b'a:one a1:two')
]
for test in tests:
- ns = namespace.Namespace(test[1])
- data = ns.serialize()
+ ns = checkers.Namespace(test[1])
+ data = ns.serialize_text()
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)
+ ns1 = checkers.deserialize_namespace(data)
self.assertEquals(ns1, ns)
def test_register(self):
- ns = namespace.Namespace(None)
+ ns = checkers.Namespace(None)
ns.register('testns', 't')
prefix = ns.resolve('testns')
self.assertEquals(prefix, 't')
@@ -48,11 +47,11 @@ class TestNamespace(TestCase):
self.assertEquals(prefix, 'o')
def test_register_bad_uri(self):
- ns = namespace.Namespace(None)
+ ns = checkers.Namespace(None)
with self.assertRaises(KeyError):
ns.register('', 'x')
def test_register_bad_prefix(self):
- ns = namespace.Namespace(None)
+ ns = checkers.Namespace(None)
with self.assertRaises(ValueError):
ns.register('std', 'x:1')
diff --git a/macaroonbakery/tests/test_oven.py b/macaroonbakery/tests/test_oven.py
new file mode 100644
index 0000000..2976e94
--- /dev/null
+++ b/macaroonbakery/tests/test_oven.py
@@ -0,0 +1,125 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+from unittest import TestCase
+
+import copy
+from datetime import datetime, timedelta
+
+import macaroonbakery
+
+EPOCH = datetime(1900, 11, 17, 19, 00, 13, 0, None)
+AGES = EPOCH + timedelta(days=10)
+
+
+class TestOven(TestCase):
+ def test_canonical_ops(self):
+ canonical_ops_tests = (
+ ('empty array', [], []),
+ ('one element', [macaroonbakery.Op('a', 'a')],
+ [macaroonbakery.Op('a', 'a')]),
+ ('all in order',
+ [macaroonbakery.Op('a', 'a'), macaroonbakery.Op('a', 'b'),
+ macaroonbakery.Op('c', 'c')],
+ [macaroonbakery.Op('a', 'a'), macaroonbakery.Op('a', 'b'),
+ macaroonbakery.Op('c', 'c')]),
+ ('out of order',
+ [macaroonbakery.Op('c', 'c'), macaroonbakery.Op('a', 'b'),
+ macaroonbakery.Op('a', 'a')],
+ [macaroonbakery.Op('a', 'a'), macaroonbakery.Op('a', 'b'),
+ macaroonbakery.Op('c', 'c')]),
+ ('with duplicates',
+ [macaroonbakery.Op('c', 'c'), macaroonbakery.Op('a', 'b'),
+ macaroonbakery.Op('a', 'a'), macaroonbakery.Op('c', 'a'),
+ macaroonbakery.Op('c', 'b'), macaroonbakery.Op('c', 'c'),
+ macaroonbakery.Op('a', 'a')],
+ [macaroonbakery.Op('a', 'a'), macaroonbakery.Op('a', 'b'),
+ macaroonbakery.Op('c', 'a'), macaroonbakery.Op('c', 'b'),
+ macaroonbakery.Op('c', 'c')]),
+ ('make sure we\'ve got the fields right',
+ [macaroonbakery.Op(entity='read', action='two'),
+ macaroonbakery.Op(entity='read', action='one'),
+ macaroonbakery.Op(entity='write', action='one')],
+ [macaroonbakery.Op(entity='read', action='one'),
+ macaroonbakery.Op(entity='read', action='two'),
+ macaroonbakery.Op(entity='write', action='one')])
+ )
+ for about, ops, expected in canonical_ops_tests:
+ new_ops = copy.copy(ops)
+ canonical_ops = macaroonbakery.canonical_ops(new_ops)
+ self.assertEquals(canonical_ops, expected)
+ # Verify that the original array isn't changed.
+ self.assertEquals(new_ops, ops)
+
+ def test_multiple_ops(self):
+ test_oven = macaroonbakery.Oven(
+ ops_store=macaroonbakery.MemoryOpsStore())
+ ops = [macaroonbakery.Op('one', 'read'),
+ macaroonbakery.Op('one', 'write'),
+ macaroonbakery.Op('two', 'read')]
+ m = test_oven.macaroon(macaroonbakery.LATEST_BAKERY_VERSION, AGES,
+ None, ops)
+ got_ops, conds = test_oven.macaroon_ops([m.macaroon])
+ self.assertEquals(len(conds), 1) # time-before caveat.
+ self.assertEquals(macaroonbakery.canonical_ops(got_ops), ops)
+
+ def test_multiple_ops_in_id(self):
+ test_oven = macaroonbakery.Oven()
+ ops = [macaroonbakery.Op('one', 'read'),
+ macaroonbakery.Op('one', 'write'),
+ macaroonbakery.Op('two', 'read')]
+ m = test_oven.macaroon(macaroonbakery.LATEST_BAKERY_VERSION, AGES,
+ None, ops)
+ got_ops, conds = test_oven.macaroon_ops([m.macaroon])
+ self.assertEquals(len(conds), 1) # time-before caveat.
+ self.assertEquals(macaroonbakery.canonical_ops(got_ops), ops)
+
+ def test_multiple_ops_in_id_with_version1(self):
+ test_oven = macaroonbakery.Oven()
+ ops = [macaroonbakery.Op('one', 'read'),
+ macaroonbakery.Op('one', 'write'),
+ macaroonbakery.Op('two', 'read')]
+ m = test_oven.macaroon(macaroonbakery.BAKERY_V1, AGES, None, ops)
+ got_ops, conds = test_oven.macaroon_ops([m.macaroon])
+ self.assertEquals(len(conds), 1) # time-before caveat.
+ self.assertEquals(macaroonbakery.canonical_ops(got_ops), ops)
+
+ def test_huge_number_of_ops_gives_small_macaroon(self):
+ test_oven = macaroonbakery.Oven(
+ ops_store=macaroonbakery.MemoryOpsStore())
+ ops = []
+ for i in range(30000):
+ ops.append(macaroonbakery.Op(entity='entity{}'.format(i),
+ action='action{}'.format(i)))
+
+ m = test_oven.macaroon(macaroonbakery.LATEST_BAKERY_VERSION, AGES,
+ None, ops)
+ got_ops, conds = test_oven.macaroon_ops([m.macaroon])
+ self.assertEquals(len(conds), 1) # time-before caveat.
+ self.assertEquals(macaroonbakery.canonical_ops(got_ops),
+ macaroonbakery.canonical_ops(ops))
+
+ data = m.serialize_json()
+ self.assertLess(len(data), 300)
+
+ def test_ops_stored_only_once(self):
+ st = macaroonbakery.MemoryOpsStore()
+ test_oven = macaroonbakery.Oven(ops_store=st)
+
+ ops = [macaroonbakery.Op('one', 'read'),
+ macaroonbakery.Op('one', 'write'),
+ macaroonbakery.Op('two', 'read')]
+
+ m = test_oven.macaroon(macaroonbakery.LATEST_BAKERY_VERSION, AGES,
+ None, ops)
+ got_ops, conds = test_oven.macaroon_ops([m.macaroon])
+ self.assertEquals(macaroonbakery.canonical_ops(got_ops),
+ macaroonbakery.canonical_ops(ops))
+
+ # Make another macaroon containing the same ops in a different order.
+ ops = [macaroonbakery.Op('one', 'write'),
+ macaroonbakery.Op('one', 'read'),
+ macaroonbakery.Op('one', 'read'),
+ macaroonbakery.Op('two', 'read')]
+ test_oven.macaroon(macaroonbakery.LATEST_BAKERY_VERSION, AGES, None,
+ ops)
+ self.assertEquals(len(st._store), 1)
diff --git a/macaroonbakery/tests/test_store.py b/macaroonbakery/tests/test_store.py
new file mode 100644
index 0000000..7bcc4c2
--- /dev/null
+++ b/macaroonbakery/tests/test_store.py
@@ -0,0 +1,21 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+from unittest import TestCase
+
+import macaroonbakery
+
+
+class TestOven(TestCase):
+ def test_mem_store(self):
+ st = macaroonbakery.MemoryKeyStore()
+
+ key, id = st.root_key()
+ self.assertEqual(len(key), 24)
+ self.assertEqual(id.decode('utf-8'), '0')
+
+ key1, id1 = st.root_key()
+ self.assertEqual(key1, key)
+ self.assertEqual(id1, id)
+
+ key2 = st.get(id)
+ self.assertEqual(key2, key)
diff --git a/macaroonbakery/third_party.py b/macaroonbakery/third_party.py
new file mode 100644
index 0000000..d43b8ad
--- /dev/null
+++ b/macaroonbakery/third_party.py
@@ -0,0 +1,53 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+from collections import namedtuple
+
+import macaroonbakery.checkers as checkers
+
+
+def legacy_namespace():
+ ''' Standard namespace for pre-version3 macaroons.
+ '''
+ ns = checkers.Namespace(None)
+ ns.register(checkers.STD_NAMESPACE, '')
+ return ns
+
+
+class ThirdPartyCaveatInfo(namedtuple(
+ 'ThirdPartyCaveatInfo',
+ 'condition, first_party_public_key, third_party_key_pair, root_key, '
+ 'caveat, version, namespace')):
+ '''ThirdPartyCaveatInfo holds the information decoded from
+ a third party caveat id.
+
+ :param: condition holds the third party condition to be discharged.
+ This is the only field that most third party dischargers will
+ need to consider.
+
+ :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.
+
+ :param: namespace object that holds the namespace of the first party
+ that created the macaroon, as encoded by the party that added the
+ third party caveat.
+ '''
+
+
+class ThirdPartyInfo(namedtuple('ThirdPartyInfo', 'version, public_key')):
+ ''' ThirdPartyInfo holds information on a given third party
+ discharge service.
+ version holds latest the bakery protocol version supported
+ by the discharger.
+ public_key holds the public nacl key of the third party.
+ '''
diff --git a/macaroonbakery/utils.py b/macaroonbakery/utils.py
index c747ad3..3b5550b 100644
--- a/macaroonbakery/utils.py
+++ b/macaroonbakery/utils.py
@@ -1,13 +1,11 @@
# 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
+from pymacaroons.serializers import json_serializer
def deserialize(json_macaroon):
@@ -52,6 +50,18 @@ def remove_base64_padding(b):
return b.rstrip(b'=')
+def raw_b64decode(s):
+ '''Base64 decode with added padding with urlsafe or not.
+
+ @param s string decode
+ @return bytes decoded
+ '''
+ if '_' or '-' in s:
+ return raw_urlsafe_b64decode(s)
+ else:
+ return base64.b64decode(add_base64_padding(s))
+
+
def raw_urlsafe_b64decode(s):
'''Base64 decode with added padding and convertion to bytes.
diff --git a/macaroonbakery/versions.py b/macaroonbakery/versions.py
new file mode 100644
index 0000000..5287c4c
--- /dev/null
+++ b/macaroonbakery/versions.py
@@ -0,0 +1,9 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+
+
+BAKERY_V0 = 0
+BAKERY_V1 = 1
+BAKERY_V2 = 2
+BAKERY_V3 = 3
+LATEST_BAKERY_VERSION = BAKERY_V3
diff --git a/requirements.txt b/requirements.txt
index 8cd09a5..06cc66e 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,7 +1,10 @@
# Copyright 2017 Canonical Ltd.
# Licensed under the LGPLv3, see LICENCE file for details.
-requests>=2.16.5
-PyNaCl>=1.1.2
-pymacaroons==0.10.0
-six>=1.10.0
+requests>=2.18.4,<3.0
+PyNaCl>=1.1.2,<2.0
+pymacaroons>=0.12.0,<1.0
+six>=1.11.0,<2.0
+protobuf>=3.4.0,<4.0
+pyRFC3339>=1.0,<2.0
+pytz>=2017.2,<2018.0
diff --git a/setup.py b/setup.py
index 989b8cc..7fbc6d3 100755
--- a/setup.py
+++ b/setup.py
@@ -11,16 +11,26 @@ from setuptools import (
PROJECT_NAME = 'macaroonbakery'
-project = __import__(PROJECT_NAME)
+
+VERSION = (0, 0, 4)
+
+
+def get_version():
+ '''Return the macaroon bakery version as a string.'''
+ return '.'.join(map(str, VERSION))
+
with open('README.rst') as readme_file:
readme = readme_file.read()
requirements = [
- 'requests>=2.16.5',
- 'PyNaCl>=1.1.2',
- 'pymacaroons>=0.10.0',
- 'six>=1.10.0',
+ 'requests>=2.18.4,<3.0',
+ 'PyNaCl>=1.1.2,<2.0',
+ 'pymacaroons>=0.12.0,<1.0',
+ 'six>=1.11.0,<2.0',
+ 'protobuf>=3.4.0,<4.0',
+ 'pyRFC3339>=1.0,<2.0',
+ 'pytz>=2017.2,<2018.0'
]
test_requirements = [
@@ -41,7 +51,7 @@ if len(distribution) == 3 and distribution[2] == 'trusty':
setup(
name=PROJECT_NAME,
- version=project.get_version(),
+ version=get_version(),
description='A Python library port for bakery, higher level operation '
'to work with macaroons',
long_description=readme,
diff --git a/tox.ini b/tox.ini
index 0fdf31c..42d4d59 100644
--- a/tox.ini
+++ b/tox.ini
@@ -8,7 +8,7 @@ envlist = py27, py35, style, docs
setenv =
PYTHONPATH = {toxinidir}:{toxinidir}/macaroonbakery
commands =
- nosetests {posargs:--quiet}
+ nosetests
deps =
-r{toxinidir}/test-requirements.txt
@@ -19,7 +19,7 @@ commands =
[testenv:lint]
usedevelop = True
-commands = flake8 --ignore E731 --show-source macaroonbakery
+commands = flake8 --show-source macaroonbakery --exclude macaroonbakery/internal/id_pb2.py
[testenv:docs]
changedir = docs