From a319ce1a9f6a69ad31a7c9bc23734528bcee500f Mon Sep 17 00:00:00 2001 From: Colin Watson Date: Fri, 14 Feb 2020 11:22:12 +0000 Subject: Import py-macaroon-bakery_1.3.1.orig.tar.gz [dgit import orig py-macaroon-bakery_1.3.1.orig.tar.gz] --- AUTHORS.rst | 8 + CONTRIBUTING.rst | 104 +++ LICENSE | 166 +++++ MANIFEST.in | 9 + PKG-INFO | 54 ++ README.rst | 35 + docs/Makefile | 177 +++++ docs/authors.rst | 1 + docs/contributing.rst | 1 + docs/index.rst | 26 + docs/installation.rst | 7 + docs/packaging.rst | 4 + docs/readme.rst | 1 + docs/usage.rst | 7 + macaroonbakery.egg-info/PKG-INFO | 54 ++ macaroonbakery.egg-info/SOURCES.txt | 77 +++ macaroonbakery.egg-info/dependency_links.txt | 1 + macaroonbakery.egg-info/not-zip-safe | 1 + macaroonbakery.egg-info/requires.txt | 15 + macaroonbakery.egg-info/top_level.txt | 1 + macaroonbakery/__init__.py | 0 macaroonbakery/_utils/__init__.py | 180 +++++ macaroonbakery/bakery/__init__.py | 141 ++++ macaroonbakery/bakery/_authorizer.py | 106 +++ macaroonbakery/bakery/_bakery.py | 72 ++ macaroonbakery/bakery/_checker.py | 418 ++++++++++++ macaroonbakery/bakery/_codec.py | 301 +++++++++ macaroonbakery/bakery/_discharge.py | 244 +++++++ macaroonbakery/bakery/_error.py | 77 +++ macaroonbakery/bakery/_identity.py | 126 ++++ macaroonbakery/bakery/_internal/__init__.py | 0 macaroonbakery/bakery/_internal/id_pb2.py | 132 ++++ macaroonbakery/bakery/_keys.py | 100 +++ macaroonbakery/bakery/_macaroon.py | 430 ++++++++++++ macaroonbakery/bakery/_oven.py | 289 ++++++++ macaroonbakery/bakery/_store.py | 77 +++ macaroonbakery/bakery/_third_party.py | 57 ++ macaroonbakery/bakery/_versions.py | 9 + macaroonbakery/checkers/__init__.py | 82 +++ macaroonbakery/checkers/_auth_context.py | 61 ++ macaroonbakery/checkers/_caveat.py | 128 ++++ macaroonbakery/checkers/_checkers.py | 249 +++++++ macaroonbakery/checkers/_conditions.py | 17 + macaroonbakery/checkers/_declared.py | 84 +++ macaroonbakery/checkers/_namespace.py | 165 +++++ macaroonbakery/checkers/_operation.py | 17 + macaroonbakery/checkers/_time.py | 67 ++ macaroonbakery/checkers/_utils.py | 13 + macaroonbakery/httpbakery/__init__.py | 55 ++ macaroonbakery/httpbakery/_browser.py | 90 +++ macaroonbakery/httpbakery/_client.py | 402 ++++++++++++ macaroonbakery/httpbakery/_discharge.py | 37 ++ macaroonbakery/httpbakery/_error.py | 207 ++++++ macaroonbakery/httpbakery/_interactor.py | 70 ++ macaroonbakery/httpbakery/_keyring.py | 60 ++ macaroonbakery/httpbakery/agent/__init__.py | 19 + macaroonbakery/httpbakery/agent/_agent.py | 185 ++++++ macaroonbakery/tests/__init__.py | 2 + macaroonbakery/tests/common.py | 119 ++++ macaroonbakery/tests/test_agent.py | 419 ++++++++++++ macaroonbakery/tests/test_authorizer.py | 136 ++++ macaroonbakery/tests/test_bakery.py | 286 ++++++++ macaroonbakery/tests/test_checker.py | 946 +++++++++++++++++++++++++++ macaroonbakery/tests/test_checkers.py | 354 ++++++++++ macaroonbakery/tests/test_client.py | 682 +++++++++++++++++++ macaroonbakery/tests/test_codec.py | 196 ++++++ macaroonbakery/tests/test_discharge.py | 517 +++++++++++++++ macaroonbakery/tests/test_discharge_all.py | 164 +++++ macaroonbakery/tests/test_httpbakery.py | 44 ++ macaroonbakery/tests/test_keyring.py | 111 ++++ macaroonbakery/tests/test_macaroon.py | 201 ++++++ macaroonbakery/tests/test_namespace.py | 59 ++ macaroonbakery/tests/test_oven.py | 124 ++++ macaroonbakery/tests/test_store.py | 21 + macaroonbakery/tests/test_time.py | 136 ++++ macaroonbakery/tests/test_utils.py | 96 +++ setup.cfg | 7 + setup.py | 72 ++ 78 files changed, 10208 insertions(+) create mode 100644 AUTHORS.rst create mode 100644 CONTRIBUTING.rst create mode 100644 LICENSE create mode 100644 MANIFEST.in create mode 100644 PKG-INFO create mode 100644 README.rst create mode 100644 docs/Makefile create mode 100644 docs/authors.rst create mode 100644 docs/contributing.rst create mode 100644 docs/index.rst create mode 100644 docs/installation.rst create mode 100644 docs/packaging.rst create mode 100644 docs/readme.rst create mode 100644 docs/usage.rst create mode 100644 macaroonbakery.egg-info/PKG-INFO create mode 100644 macaroonbakery.egg-info/SOURCES.txt create mode 100644 macaroonbakery.egg-info/dependency_links.txt create mode 100644 macaroonbakery.egg-info/not-zip-safe create mode 100644 macaroonbakery.egg-info/requires.txt create mode 100644 macaroonbakery.egg-info/top_level.txt create mode 100644 macaroonbakery/__init__.py create mode 100644 macaroonbakery/_utils/__init__.py create mode 100644 macaroonbakery/bakery/__init__.py create mode 100644 macaroonbakery/bakery/_authorizer.py create mode 100644 macaroonbakery/bakery/_bakery.py create mode 100644 macaroonbakery/bakery/_checker.py create mode 100644 macaroonbakery/bakery/_codec.py create mode 100644 macaroonbakery/bakery/_discharge.py create mode 100644 macaroonbakery/bakery/_error.py create mode 100644 macaroonbakery/bakery/_identity.py create mode 100644 macaroonbakery/bakery/_internal/__init__.py create mode 100644 macaroonbakery/bakery/_internal/id_pb2.py create mode 100644 macaroonbakery/bakery/_keys.py create mode 100644 macaroonbakery/bakery/_macaroon.py create mode 100644 macaroonbakery/bakery/_oven.py create mode 100644 macaroonbakery/bakery/_store.py create mode 100644 macaroonbakery/bakery/_third_party.py create mode 100644 macaroonbakery/bakery/_versions.py create mode 100644 macaroonbakery/checkers/__init__.py create mode 100644 macaroonbakery/checkers/_auth_context.py create mode 100644 macaroonbakery/checkers/_caveat.py create mode 100644 macaroonbakery/checkers/_checkers.py create mode 100644 macaroonbakery/checkers/_conditions.py create mode 100644 macaroonbakery/checkers/_declared.py create mode 100644 macaroonbakery/checkers/_namespace.py create mode 100644 macaroonbakery/checkers/_operation.py create mode 100644 macaroonbakery/checkers/_time.py create mode 100644 macaroonbakery/checkers/_utils.py create mode 100644 macaroonbakery/httpbakery/__init__.py create mode 100644 macaroonbakery/httpbakery/_browser.py create mode 100644 macaroonbakery/httpbakery/_client.py create mode 100644 macaroonbakery/httpbakery/_discharge.py create mode 100644 macaroonbakery/httpbakery/_error.py create mode 100644 macaroonbakery/httpbakery/_interactor.py create mode 100644 macaroonbakery/httpbakery/_keyring.py create mode 100644 macaroonbakery/httpbakery/agent/__init__.py create mode 100644 macaroonbakery/httpbakery/agent/_agent.py create mode 100644 macaroonbakery/tests/__init__.py create mode 100644 macaroonbakery/tests/common.py create mode 100644 macaroonbakery/tests/test_agent.py create mode 100644 macaroonbakery/tests/test_authorizer.py create mode 100644 macaroonbakery/tests/test_bakery.py create mode 100644 macaroonbakery/tests/test_checker.py create mode 100644 macaroonbakery/tests/test_checkers.py create mode 100644 macaroonbakery/tests/test_client.py create mode 100644 macaroonbakery/tests/test_codec.py create mode 100644 macaroonbakery/tests/test_discharge.py create mode 100644 macaroonbakery/tests/test_discharge_all.py create mode 100644 macaroonbakery/tests/test_httpbakery.py create mode 100644 macaroonbakery/tests/test_keyring.py create mode 100644 macaroonbakery/tests/test_macaroon.py create mode 100644 macaroonbakery/tests/test_namespace.py create mode 100644 macaroonbakery/tests/test_oven.py create mode 100644 macaroonbakery/tests/test_store.py create mode 100644 macaroonbakery/tests/test_time.py create mode 100644 macaroonbakery/tests/test_utils.py create mode 100644 setup.cfg create mode 100755 setup.py diff --git a/AUTHORS.rst b/AUTHORS.rst new file mode 100644 index 0000000..442654b --- /dev/null +++ b/AUTHORS.rst @@ -0,0 +1,8 @@ +======= +Credits +======= + +Development Lead +---------------- + +* Juju UI Team diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst new file mode 100644 index 0000000..2993720 --- /dev/null +++ b/CONTRIBUTING.rst @@ -0,0 +1,104 @@ +============ +Contributing +============ + +Contributions are welcome, and they are greatly appreciated! Every +little bit helps, and credit will always be given. + +You can contribute in many ways: + +Types of Contributions +---------------------- + +Report Bugs +~~~~~~~~~~~ + +Report bugs at https://github.com/go-macaroon-bakery/py-macaroon-bakery/issues. + +If you are reporting a bug, please include: + +* Your operating system name and version. +* Any details about your local setup that might be helpful in troubleshooting. +* Detailed steps to reproduce the bug. + +Fix Bugs +~~~~~~~~ + +Look through the GitHub issues for bugs. Anything tagged with "bug" +is open to whoever wants to implement it. + +Implement Features +~~~~~~~~~~~~~~~~~~ + +Look through the GitHub issues for features. Anything tagged with "feature" +is open to whoever wants to implement it. + +Write Documentation +~~~~~~~~~~~~~~~~~~~ + +The macaroon bakery could always use more documentation, whether as part of the +official macaroon bakery docs, in docstrings, or even on the web in blog posts, +articles, and such. + +Submit Feedback +~~~~~~~~~~~~~~~ + +The best way to send feedback is to file an issue at +https://github.com/go-macaroon-bakery/py-macaroon-bakery/issues. + +If you are proposing a feature: + +* Explain in detail how it would work. +* Keep the scope as narrow as possible, to make it easier to implement. + +Get Started! +------------ + +Ready to contribute? Here's how to set up `py-macaroon-bakery` for local +development. + +1. Fork the `py-macaroon-bakery` repo on GitHub. +2. Clone your fork locally:: + + $ git clone git@github.com:your_name_here/py-macaroon-bakery.git + +3. Prepare your development environment:: + + $ make devenv + +4. Create a branch for local development:: + + $ git checkout -b name-of-your-bugfix-or-feature + + Now you can make your changes locally. + +5. When you're done making changes, check that your changes pass flake8 and the +tests, including testing other Python versions with tox:: + + $ make check + +6. Commit your changes and push your branch to GitHub:: + + $ git add . + $ git commit -m "Your detailed description of your changes." + $ git push origin name-of-your-bugfix-or-feature + +7. Submit a pull request through the GitHub website. + +Pull Request Guidelines +----------------------- + +Before you submit a pull request, check that it meets these guidelines: + +1. The pull request should include tests. +2. If the pull request adds functionality, the docs should be updated. Put + your new functionality into a function with a docstring, and add the + feature to the list in README.rst. +3. The pull request should work for Python 2.7, 3+ and for PyPy. + +Tips +---- + +To run a subset of tests:: + + $ devenv/bin/nosetests macaroonbakery/tests/... diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..341c30b --- /dev/null +++ b/LICENSE @@ -0,0 +1,166 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + + This version of the GNU Lesser General Public License incorporates +the terms and conditions of version 3 of the GNU General Public +License, supplemented by the additional permissions listed below. + + 0. Additional Definitions. + + As used herein, "this License" refers to version 3 of the GNU Lesser +General Public License, and the "GNU GPL" refers to version 3 of the GNU +General Public License. + + "The Library" refers to a covered work governed by this License, +other than an Application or a Combined Work as defined below. + + An "Application" is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + + A "Combined Work" is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the "Linked +Version". + + The "Minimal Corresponding Source" for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + + The "Corresponding Application Code" for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + + 1. Exception to Section 3 of the GNU GPL. + + You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + + 2. Conveying Modified Versions. + + If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + + a) under this License, provided that you make a good faith effort to + ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or + + b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + + 3. Object Code Incorporating Material from Library Header Files. + + The object code form of an Application may incorporate material from +a header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + + a) Give prominent notice with each copy of the object code that the + Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the object code with a copy of the GNU GPL and this license + document. + + 4. Combined Works. + + You may convey a Combined Work under terms of your choice that, +taken together, effectively do not restrict modification of the +portions of the Library contained in the Combined Work and reverse +engineering for debugging such modifications, if you also do each of +the following: + + a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the Combined Work with a copy of the GNU GPL and this license + document. + + c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. + + d) Do one of the following: + + 0) Convey the Minimal Corresponding Source under the terms of this + License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + + 1) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (a) uses at run time + a copy of the Library already present on the user's computer + system, and (b) will operate properly with a modified version + of the Library that is interface-compatible with the Linked + Version. + + e) Provide Installation Information, but only if you would otherwise + be required to provide such information under section 6 of the + GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the + Application with a modified version of the Linked Version. (If + you use option 4d0, the Installation Information must accompany + the Minimal Corresponding Source and Corresponding Application + Code. If you use option 4d1, you must provide the Installation + Information in the manner specified by section 6 of the GNU GPL + for conveying Corresponding Source.) + + 5. Combined Libraries. + + You may place library facilities that are a work based on the +Library side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + + a) Accompany the combined library with a copy of the same work based + on the Library, uncombined with any other library facilities, + conveyed under the terms of this License. + + b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + + 6. Revised Versions of the GNU Lesser General Public License. + + The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + + Each version is given a distinguishing version number. If the +Library as you received it specifies that a certain numbered version +of the GNU Lesser General Public License "or any later version" +applies to it, you have the option of following the terms and +conditions either of that published version or of any later version +published by the Free Software Foundation. If the Library as you +received it does not specify a version number of the GNU Lesser +General Public License, you may choose any version of the GNU Lesser +General Public License ever published by the Free Software Foundation. + + If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. + diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..68e1903 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,9 @@ +include AUTHORS.rst +include CONTRIBUTING.rst +include LICENSE +include README.rst + +recursive-exclude * __pycache__ +recursive-exclude * *.py[co] + +recursive-include docs *.rst Makefile diff --git a/PKG-INFO b/PKG-INFO new file mode 100644 index 0000000..a3c2272 --- /dev/null +++ b/PKG-INFO @@ -0,0 +1,54 @@ +Metadata-Version: 1.1 +Name: macaroonbakery +Version: 1.3.1 +Summary: A Python library port for bakery, higher level operation to work with macaroons +Home-page: https://github.com/go-macaroon-bakery/py-macaroon-bakery +Author: Juju UI Team +Author-email: juju-gui@lists.ubuntu.com +License: LGPL3 +Description: =============== + Macaroon Bakery + =============== + + A Python library for working with macaroons. + + + Installation + ------------ + The easiest way to install macaroonbakery is via pip:: + + $ pip install macaroonbakery + + macaroonbakery was developed around pymacaroons. On ubuntu, you + can get libsodium from a ppa:: + + $ sudo add-apt-repository ppa:yellow/ppa -y + $ apt-get install libsodium13 + + Usage + ----- + Interacting with a protected url, you can use the BakeryAuth provided to deal + with the macaroon bakery + + >>> from macaroonbakery import httpbakery + >>> jar = requests.cookies.RequestsCookieJar() + >>> resp = requests.get('some protected url', + cookies=jar, + auth=httpbakery.BakeryAuth(cookies=jar)) + >>> resp.raise_for_status() + + + You can use any cookie storage you'd like so next subsequent calls the macaroon + saved in the cookie jar will be directly used and will not require + any other authentication (for example, cookielib.FileCookieJar). + +Keywords: macaroon cookie +Platform: UNKNOWN +Classifier: Development Status :: 2 - Pre-Alpha +Classifier: Intended Audience :: Developers +Classifier: License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3) +Classifier: Natural Language :: English +Classifier: Programming Language :: Python :: 2 +Classifier: Programming Language :: Python :: 2.7 +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3.5 diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..c3c06de --- /dev/null +++ b/README.rst @@ -0,0 +1,35 @@ +=============== +Macaroon Bakery +=============== + +A Python library for working with macaroons. + + +Installation +------------ +The easiest way to install macaroonbakery is via pip:: + + $ pip install macaroonbakery + +macaroonbakery was developed around pymacaroons. On ubuntu, you +can get libsodium from a ppa:: + + $ sudo add-apt-repository ppa:yellow/ppa -y + $ apt-get install libsodium13 + +Usage +----- +Interacting with a protected url, you can use the BakeryAuth provided to deal +with the macaroon bakery + + >>> from macaroonbakery import httpbakery + >>> jar = requests.cookies.RequestsCookieJar() + >>> resp = requests.get('some protected url', + cookies=jar, + auth=httpbakery.BakeryAuth(cookies=jar)) + >>> resp.raise_for_status() + + +You can use any cookie storage you'd like so next subsequent calls the macaroon +saved in the cookie jar will be directly used and will not require +any other authentication (for example, cookielib.FileCookieJar). diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..80c937f --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,177 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +PAPER = +BUILDDIR = _build + +# User-friendly check for sphinx-build +ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) +$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) +endif + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . +# the i18n builder cannot share the environment and doctrees with the others +I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . + +.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext + +help: + @echo "Please use \`make ' where is one of" + @echo " html to make standalone HTML files" + @echo " dirhtml to make HTML files named index.html in directories" + @echo " singlehtml to make a single large HTML file" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " qthelp to make HTML files and a qthelp project" + @echo " devhelp to make HTML files and a Devhelp project" + @echo " epub to make an epub" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " latexpdf to make LaTeX files and run them through pdflatex" + @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" + @echo " text to make text files" + @echo " man to make manual pages" + @echo " texinfo to make Texinfo files" + @echo " info to make Texinfo files and run them through makeinfo" + @echo " gettext to make PO message catalogs" + @echo " changes to make an overview of all changed/added/deprecated items" + @echo " xml to make Docutils-native XML files" + @echo " pseudoxml to make pseudoxml-XML files for display purposes" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" + +clean: + rm -rf $(BUILDDIR)/* + +html: + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +dirhtml: + $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." + +singlehtml: + $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml + @echo + @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." + +pickle: + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle + @echo + @echo "Build finished; now you can process the pickle files." + +json: + $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json + @echo + @echo "Build finished; now you can process the JSON files." + +htmlhelp: + $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp + @echo + @echo "Build finished; now you can run HTML Help Workshop with the" \ + ".hhp project file in $(BUILDDIR)/htmlhelp." + +qthelp: + $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp + @echo + @echo "Build finished; now you can run "qcollectiongenerator" with the" \ + ".qhcp project file in $(BUILDDIR)/qthelp, like this:" + @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/py-macaroon-bakery.qhcp" + @echo "To view the help file:" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/py-macaroon-bakery.qhc" + +devhelp: + $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp + @echo + @echo "Build finished." + @echo "To view the help file:" + @echo "# mkdir -p $$HOME/.local/share/devhelp/py-macaroon-bakery" + @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/py-macaroon-bakery" + @echo "# devhelp" + +epub: + $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub + @echo + @echo "Build finished. The epub file is in $(BUILDDIR)/epub." + +latex: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo + @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." + @echo "Run \`make' in that directory to run these through (pdf)latex" \ + "(use \`make latexpdf' here to do that automatically)." + +latexpdf: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through pdflatex..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +latexpdfja: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through platex and dvipdfmx..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +text: + $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text + @echo + @echo "Build finished. The text files are in $(BUILDDIR)/text." + +man: + $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man + @echo + @echo "Build finished. The manual pages are in $(BUILDDIR)/man." + +texinfo: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo + @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." + @echo "Run \`make' in that directory to run these through makeinfo" \ + "(use \`make info' here to do that automatically)." + +info: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo "Running Texinfo files through makeinfo..." + make -C $(BUILDDIR)/texinfo info + @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." + +gettext: + $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale + @echo + @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." + +changes: + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes + @echo + @echo "The overview file is in $(BUILDDIR)/changes." + +linkcheck: + $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck + @echo + @echo "Link check complete; look for any errors in the above output " \ + "or in $(BUILDDIR)/linkcheck/output.txt." + +doctest: + $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + @echo "Testing of doctests in the sources finished, look at the " \ + "results in $(BUILDDIR)/doctest/output.txt." + +xml: + $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml + @echo + @echo "Build finished. The XML files are in $(BUILDDIR)/xml." + +pseudoxml: + $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml + @echo + @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." diff --git a/docs/authors.rst b/docs/authors.rst new file mode 100644 index 0000000..e122f91 --- /dev/null +++ b/docs/authors.rst @@ -0,0 +1 @@ +.. include:: ../AUTHORS.rst diff --git a/docs/contributing.rst b/docs/contributing.rst new file mode 100644 index 0000000..e582053 --- /dev/null +++ b/docs/contributing.rst @@ -0,0 +1 @@ +.. include:: ../CONTRIBUTING.rst diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..5ff2139 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,26 @@ +.. bakery documentation master file, created by + sphinx-quickstart on Tue Jul 9 22:26:36 2013. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Welcome to Macaroon Bakery Library's documentation! +=================================================== + +Contents: + +.. toctree:: + :maxdepth: 2 + + readme + installation + usage + contributing + packaging + authors + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/installation.rst b/docs/installation.rst new file mode 100644 index 0000000..6ccb0ae --- /dev/null +++ b/docs/installation.rst @@ -0,0 +1,7 @@ +============ +Installation +============ + +At the command line:: + + $ pip install macaroonbakery diff --git a/docs/packaging.rst b/docs/packaging.rst new file mode 100644 index 0000000..40009c1 --- /dev/null +++ b/docs/packaging.rst @@ -0,0 +1,4 @@ +========= +Packaging +========= + diff --git a/docs/readme.rst b/docs/readme.rst new file mode 100644 index 0000000..72a3355 --- /dev/null +++ b/docs/readme.rst @@ -0,0 +1 @@ +.. include:: ../README.rst diff --git a/docs/usage.rst b/docs/usage.rst new file mode 100644 index 0000000..21fac9c --- /dev/null +++ b/docs/usage.rst @@ -0,0 +1,7 @@ +======== +Usage +======== + +To use the macaroon bakery Python library for handling macaroon higher level function:: + + import macaroonbakery diff --git a/macaroonbakery.egg-info/PKG-INFO b/macaroonbakery.egg-info/PKG-INFO new file mode 100644 index 0000000..a3c2272 --- /dev/null +++ b/macaroonbakery.egg-info/PKG-INFO @@ -0,0 +1,54 @@ +Metadata-Version: 1.1 +Name: macaroonbakery +Version: 1.3.1 +Summary: A Python library port for bakery, higher level operation to work with macaroons +Home-page: https://github.com/go-macaroon-bakery/py-macaroon-bakery +Author: Juju UI Team +Author-email: juju-gui@lists.ubuntu.com +License: LGPL3 +Description: =============== + Macaroon Bakery + =============== + + A Python library for working with macaroons. + + + Installation + ------------ + The easiest way to install macaroonbakery is via pip:: + + $ pip install macaroonbakery + + macaroonbakery was developed around pymacaroons. On ubuntu, you + can get libsodium from a ppa:: + + $ sudo add-apt-repository ppa:yellow/ppa -y + $ apt-get install libsodium13 + + Usage + ----- + Interacting with a protected url, you can use the BakeryAuth provided to deal + with the macaroon bakery + + >>> from macaroonbakery import httpbakery + >>> jar = requests.cookies.RequestsCookieJar() + >>> resp = requests.get('some protected url', + cookies=jar, + auth=httpbakery.BakeryAuth(cookies=jar)) + >>> resp.raise_for_status() + + + You can use any cookie storage you'd like so next subsequent calls the macaroon + saved in the cookie jar will be directly used and will not require + any other authentication (for example, cookielib.FileCookieJar). + +Keywords: macaroon cookie +Platform: UNKNOWN +Classifier: Development Status :: 2 - Pre-Alpha +Classifier: Intended Audience :: Developers +Classifier: License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3) +Classifier: Natural Language :: English +Classifier: Programming Language :: Python :: 2 +Classifier: Programming Language :: Python :: 2.7 +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3.5 diff --git a/macaroonbakery.egg-info/SOURCES.txt b/macaroonbakery.egg-info/SOURCES.txt new file mode 100644 index 0000000..f3ac62a --- /dev/null +++ b/macaroonbakery.egg-info/SOURCES.txt @@ -0,0 +1,77 @@ +AUTHORS.rst +CONTRIBUTING.rst +LICENSE +MANIFEST.in +README.rst +setup.cfg +setup.py +docs/Makefile +docs/authors.rst +docs/contributing.rst +docs/index.rst +docs/installation.rst +docs/packaging.rst +docs/readme.rst +docs/usage.rst +macaroonbakery/__init__.py +macaroonbakery.egg-info/PKG-INFO +macaroonbakery.egg-info/SOURCES.txt +macaroonbakery.egg-info/dependency_links.txt +macaroonbakery.egg-info/not-zip-safe +macaroonbakery.egg-info/requires.txt +macaroonbakery.egg-info/top_level.txt +macaroonbakery/_utils/__init__.py +macaroonbakery/bakery/__init__.py +macaroonbakery/bakery/_authorizer.py +macaroonbakery/bakery/_bakery.py +macaroonbakery/bakery/_checker.py +macaroonbakery/bakery/_codec.py +macaroonbakery/bakery/_discharge.py +macaroonbakery/bakery/_error.py +macaroonbakery/bakery/_identity.py +macaroonbakery/bakery/_keys.py +macaroonbakery/bakery/_macaroon.py +macaroonbakery/bakery/_oven.py +macaroonbakery/bakery/_store.py +macaroonbakery/bakery/_third_party.py +macaroonbakery/bakery/_versions.py +macaroonbakery/bakery/_internal/__init__.py +macaroonbakery/bakery/_internal/id_pb2.py +macaroonbakery/checkers/__init__.py +macaroonbakery/checkers/_auth_context.py +macaroonbakery/checkers/_caveat.py +macaroonbakery/checkers/_checkers.py +macaroonbakery/checkers/_conditions.py +macaroonbakery/checkers/_declared.py +macaroonbakery/checkers/_namespace.py +macaroonbakery/checkers/_operation.py +macaroonbakery/checkers/_time.py +macaroonbakery/checkers/_utils.py +macaroonbakery/httpbakery/__init__.py +macaroonbakery/httpbakery/_browser.py +macaroonbakery/httpbakery/_client.py +macaroonbakery/httpbakery/_discharge.py +macaroonbakery/httpbakery/_error.py +macaroonbakery/httpbakery/_interactor.py +macaroonbakery/httpbakery/_keyring.py +macaroonbakery/httpbakery/agent/__init__.py +macaroonbakery/httpbakery/agent/_agent.py +macaroonbakery/tests/__init__.py +macaroonbakery/tests/common.py +macaroonbakery/tests/test_agent.py +macaroonbakery/tests/test_authorizer.py +macaroonbakery/tests/test_bakery.py +macaroonbakery/tests/test_checker.py +macaroonbakery/tests/test_checkers.py +macaroonbakery/tests/test_client.py +macaroonbakery/tests/test_codec.py +macaroonbakery/tests/test_discharge.py +macaroonbakery/tests/test_discharge_all.py +macaroonbakery/tests/test_httpbakery.py +macaroonbakery/tests/test_keyring.py +macaroonbakery/tests/test_macaroon.py +macaroonbakery/tests/test_namespace.py +macaroonbakery/tests/test_oven.py +macaroonbakery/tests/test_store.py +macaroonbakery/tests/test_time.py +macaroonbakery/tests/test_utils.py \ No newline at end of file diff --git a/macaroonbakery.egg-info/dependency_links.txt b/macaroonbakery.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/macaroonbakery.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/macaroonbakery.egg-info/not-zip-safe b/macaroonbakery.egg-info/not-zip-safe new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/macaroonbakery.egg-info/not-zip-safe @@ -0,0 +1 @@ + diff --git a/macaroonbakery.egg-info/requires.txt b/macaroonbakery.egg-info/requires.txt new file mode 100644 index 0000000..52b6944 --- /dev/null +++ b/macaroonbakery.egg-info/requires.txt @@ -0,0 +1,15 @@ +PyNaCl<2.0,>=1.1.2 +protobuf<4.0,>=3.0.0 +pyRFC3339<2.0,>=1.0 +pymacaroons<1.0,>=0.12.0 +requests<3.0,>=2.18.1 +six<2.0,>=1.11.0 + +[:python_full_version < "2.7.9"] +cryptography==1.3.2 +ndg_httpsclient==0.3.3 +pyOpenSSL==16.0.0 +pyasn1==0.1.9 + +[:python_version < "3"] +ipaddress diff --git a/macaroonbakery.egg-info/top_level.txt b/macaroonbakery.egg-info/top_level.txt new file mode 100644 index 0000000..57bab63 --- /dev/null +++ b/macaroonbakery.egg-info/top_level.txt @@ -0,0 +1 @@ +macaroonbakery diff --git a/macaroonbakery/__init__.py b/macaroonbakery/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/macaroonbakery/_utils/__init__.py b/macaroonbakery/_utils/__init__.py new file mode 100644 index 0000000..977cdbe --- /dev/null +++ b/macaroonbakery/_utils/__init__.py @@ -0,0 +1,180 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +import base64 +import binascii +import ipaddress +import json +import webbrowser +from datetime import datetime + +import six +from pymacaroons import Macaroon +from pymacaroons.serializers import json_serializer + +import six.moves.http_cookiejar as http_cookiejar +from six.moves.urllib.parse import urlparse + + +def to_bytes(s): + '''Return s as a bytes type, using utf-8 encoding if necessary. + @param s string or bytes + @return bytes + ''' + if isinstance(s, six.binary_type): + return s + if isinstance(s, six.string_types): + return s.encode('utf-8') + raise TypeError('want string or bytes, got {}', type(s)) + + +def macaroon_from_dict(json_macaroon): + '''Return a pymacaroons.Macaroon object from the given + JSON-deserialized dict. + + @param JSON-encoded macaroon as dict + @return the deserialized macaroon object. + ''' + return Macaroon.deserialize(json.dumps(json_macaroon), + json_serializer.JsonSerializer()) + + +def macaroon_to_dict(macaroon): + '''Turn macaroon into JSON-serializable dict object + @param pymacaroons.Macaroon. + ''' + return json.loads(macaroon.serialize(json_serializer.JsonSerializer())) + + +def macaroon_to_json_string(macaroon): + '''Serialize macaroon object to a JSON-encoded string. + + @param macaroon object to be serialized. + @return a string serialization form of the macaroon. + ''' + return macaroon.serialize(json_serializer.JsonSerializer()) + + +def _add_base64_padding(b): + '''Add padding to base64 encoded bytes. + + pymacaroons does not give padded base64 bytes from serialization. + + @param bytes b to be padded. + @return a padded bytes. + ''' + return b + b'=' * (-len(b) % 4) + + +def _remove_base64_padding(b): + '''Remove padding from base64 encoded bytes. + + pymacaroons does not give padded base64 bytes from serialization. + + @param bytes b to be padded. + @return a padded bytes. + ''' + return b.rstrip(b'=') + + +def b64decode(s): + '''Base64 decodes a base64-encoded string in URL-safe + or normal format, with or without padding. + The argument may be string or bytes. + + @param s bytes decode + @return bytes decoded + @raises ValueError on failure + ''' + # add padding if necessary. + s = to_bytes(s) + if not s.endswith(b'='): + s = s + b'=' * (-len(s) % 4) + try: + if '_' or '-' in s: + return base64.urlsafe_b64decode(s) + else: + return base64.b64decode(s) + except (TypeError, binascii.Error) as e: + raise ValueError(str(e)) + + +def raw_urlsafe_b64encode(b): + '''Base64 encode using URL-safe encoding with padding removed. + + @param b bytes to decode + @return bytes decoded + ''' + b = to_bytes(b) + b = base64.urlsafe_b64encode(b) + b = b.rstrip(b'=') # strip padding + return b + + +def visit_page_with_browser(visit_url): + '''Open a browser so the user can validate its identity. + + @param visit_url: where to prove your identity. + ''' + webbrowser.open(visit_url, new=1) + print('Opening an authorization web page in your browser.') + print('If it does not open, please open this URL:\n', visit_url, '\n') + + +def cookie( + url, + name, + value, + expires=None): + '''Return a new Cookie using a slightly more + friendly API than that provided by six.moves.http_cookiejar + + @param name The cookie name {str} + @param value The cookie value {str} + @param url The URL path of the cookie {str} + @param expires The expiry time of the cookie {datetime}. If provided, + it must be a naive timestamp in UTC. + ''' + u = urlparse(url) + domain = u.hostname + if '.' not in domain and not _is_ip_addr(domain): + domain += ".local" + port = str(u.port) if u.port is not None else None + secure = u.scheme == 'https' + if expires is not None: + if expires.tzinfo is not None: + raise ValueError('Cookie expiration must be a naive datetime') + expires = (expires - datetime(1970, 1, 1)).total_seconds() + return http_cookiejar.Cookie( + version=0, + name=name, + value=value, + port=port, + port_specified=port is not None, + domain=domain, + domain_specified=True, + domain_initial_dot=False, + path=u.path, + path_specified=True, + secure=secure, + expires=expires, + discard=False, + comment=None, + comment_url=None, + rest=None, + rfc2109=False, + ) + + +def _is_ip_addr(h): + if six.PY2: + # the python2.7 backport of ipaddr needs a bytestring passed in + try: + h = h.decode('ascii') + except UnicodeDecodeError: + # If there are non-ascii chars it's not an address anyway + return False + try: + ipaddress.ip_address(h) + except ValueError: + return False + return True diff --git a/macaroonbakery/bakery/__init__.py b/macaroonbakery/bakery/__init__.py new file mode 100644 index 0000000..4b973e9 --- /dev/null +++ b/macaroonbakery/bakery/__init__.py @@ -0,0 +1,141 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. + +from ._versions import ( + VERSION_0, + VERSION_1, + VERSION_2, + VERSION_3, + LATEST_VERSION, +) +from ._authorizer import ( + ACLAuthorizer, + Authorizer, + AuthorizerFunc, + ClosedAuthorizer, + EVERYONE, +) +from ._codec import ( + decode_caveat, + encode_caveat, + encode_uvarint, +) +from ._checker import ( + AuthChecker, + AuthInfo, + Checker, + LOGIN_OP, + Op, +) +from ._error import ( + AuthInitError, + CaveatNotRecognizedError, + DischargeRequiredError, + IdentityError, + PermissionDenied, + ThirdPartyCaveatCheckFailed, + ThirdPartyInfoNotFound, + VerificationError, +) +from ._identity import ( + ACLIdentity, + Identity, + IdentityClient, + NoIdentities, + SimpleIdentity, +) +from ._keys import ( + generate_key, + PrivateKey, + PublicKey, +) +from ._store import ( + MemoryOpsStore, + MemoryKeyStore, +) +from ._third_party import ( + ThirdPartyCaveatInfo, + ThirdPartyInfo, + legacy_namespace, +) +from ._macaroon import ( + Macaroon, + MacaroonJSONDecoder, + MacaroonJSONEncoder, + ThirdPartyLocator, + ThirdPartyStore, + macaroon_version, +) +from ._discharge import ( + ThirdPartyCaveatChecker, + discharge, + discharge_all, + local_third_party_caveat, +) +from ._oven import ( + Oven, + canonical_ops, +) +from ._bakery import Bakery +from macaroonbakery._utils import ( + b64decode, + macaroon_to_dict, +) + +__all__ = [ + 'ACLAuthorizer', + 'ACLIdentity', + 'AuthChecker', + 'AuthInfo', + 'AuthInitError', + 'Authorizer', + 'AuthorizerFunc', + 'VERSION_0', + 'VERSION_1', + 'VERSION_2', + 'VERSION_3', + 'Bakery', + 'CaveatNotRecognizedError', + 'Checker', + 'ClosedAuthorizer', + 'DischargeRequiredError', + 'EVERYONE', + 'Identity', + 'IdentityClient', + 'IdentityError', + 'LATEST_VERSION', + 'LOGIN_OP', + 'Macaroon', + 'MacaroonJSONDecoder', + 'MacaroonJSONEncoder', + 'MemoryKeyStore', + 'MemoryOpsStore', + 'NoIdentities', + 'Op', + 'Oven', + 'PermissionDenied', + 'PrivateKey', + 'PublicKey', + 'SimpleIdentity', + 'ThirdPartyCaveatCheckFailed', + 'ThirdPartyCaveatChecker', + 'ThirdPartyCaveatInfo', + 'ThirdPartyInfo', + 'ThirdPartyInfoNotFound', + 'ThirdPartyLocator', + 'ThirdPartyStore', + 'VERSION', + 'VerificationError', + 'b64decode', + 'canonical_ops', + 'decode_caveat', + 'discharge', + 'discharge_all', + 'encode_caveat', + 'encode_uvarint', + 'generate_key', + 'legacy_namespace', + 'local_third_party_caveat', + 'macaroon_to_dict', + 'macaroon_version', +] diff --git a/macaroonbakery/bakery/_authorizer.py b/macaroonbakery/bakery/_authorizer.py new file mode 100644 index 0000000..f900430 --- /dev/null +++ b/macaroonbakery/bakery/_authorizer.py @@ -0,0 +1,106 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +import abc + +from ._identity import ACLIdentity + +# EVERYONE is recognized by ACLAuthorizer as the name of a +# group that has everyone in it. +EVERYONE = 'everyone' + + +class Authorizer(object): + ''' Used to check whether a given user is allowed to perform a set of + operations. + ''' + __metaclass__ = abc.ABCMeta + + @abc.abstractmethod + def authorize(self, ctx, id, ops): + ''' Checks whether the given identity (which will be None when there is + no authenticated user) is allowed to perform the given operations. + It should raise an exception only when the authorization cannot be + determined, not when the user has been denied access. + + On success, each element of allowed holds whether the respective + element of ops has been allowed, and caveats holds any additional + third party caveats that apply. + If allowed is shorter then ops, the additional elements are assumed to + be False. + ctx(AuthContext) is the context of the authorization request. + :return: a list of boolean and a list of caveats + ''' + raise NotImplementedError('authorize method must be defined in ' + 'subclass') + + +class AuthorizerFunc(Authorizer): + ''' Implements a simplified version of Authorizer that operates on a single + operation at a time. + ''' + def __init__(self, f): + ''' + :param f: a function that takes an identity that operates on a single + operation at a time. Will return if this op is allowed as a boolean and + and a list of caveat that holds any additional third party caveats + that apply. + ''' + self._f = f + + def authorize(self, ctx, identity, ops): + '''Implements Authorizer.authorize by calling f with the given identity + for each operation. + ''' + allowed = [] + caveats = [] + for op in ops: + ok, fcaveats = self._f(ctx, identity, op) + allowed.append(ok) + if fcaveats is not None: + caveats.extend(fcaveats) + return allowed, caveats + + +class ACLAuthorizer(Authorizer): + ''' ACLAuthorizer is an Authorizer implementation that will check access + control list (ACL) membership of users. It uses get_acl to find out + the ACLs that apply to the requested operations and will authorize an + operation if an ACL contains the group "everyone" or if the identity is + an instance of ACLIdentity and its allow method returns True for the ACL. + ''' + def __init__(self, get_acl, allow_public=False): + ''' + :param get_acl get_acl will be called with an auth context and an Op. + It should return the ACL that applies (an array of string ids). + If an entity cannot be found or the action is not recognised, + get_acl should return an empty list but no error. + :param allow_public: boolean, If True and an ACL contains "everyone", + then authorization will be granted even if there is no logged in user. + ''' + self._allow_public = allow_public + self._get_acl = get_acl + + def authorize(self, ctx, identity, ops): + '''Implements Authorizer.authorize by calling identity.allow to + determine whether the identity is a member of the ACLs associated with + the given operations. + ''' + if len(ops) == 0: + # Anyone is allowed to do nothing. + return [], [] + allowed = [False] * len(ops) + has_allow = isinstance(identity, ACLIdentity) + for i, op in enumerate(ops): + acl = self._get_acl(ctx, op) + if has_allow: + allowed[i] = identity.allow(ctx, acl) + else: + allowed[i] = self._allow_public and EVERYONE in acl + return allowed, [] + + +class ClosedAuthorizer(Authorizer): + ''' An Authorizer implementation that will never authorize anything. + ''' + def authorize(self, ctx, id, ops): + return [False] * len(ops), [] diff --git a/macaroonbakery/bakery/_bakery.py b/macaroonbakery/bakery/_bakery.py new file mode 100644 index 0000000..8fac9ce --- /dev/null +++ b/macaroonbakery/bakery/_bakery.py @@ -0,0 +1,72 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. + +from ._authorizer import ClosedAuthorizer +from ._checker import Checker +import macaroonbakery.checkers as checkers +from ._oven import Oven + + +class Bakery(object): + '''Convenience class that contains both an Oven and a Checker. + ''' + def __init__(self, location=None, locator=None, ops_store=None, key=None, + identity_client=None, checker=None, root_key_store=None, + authorizer=ClosedAuthorizer()): + '''Returns a new Bakery instance which combines an Oven with a + Checker for the convenience of callers that wish to use both + together. + @param checker holds the checker used to check first party caveats. + If this is None, it will use checkers.Checker(None). + @param root_key_store holds the root key store to use. + If you need to use a different root key store for different operations, + you'll need to pass a root_key_store_for_ops value to Oven directly. + @param root_key_store If this is None, it will use MemoryKeyStore(). + Note that that is almost certain insufficient for production services + that are spread across multiple instances or that need + to persist keys across restarts. + @param locator is used to find out information on third parties when + adding third party caveats. If this is None, no non-local third + party caveats can be added. + @param key holds the private key of the oven. If this is None, + no third party caveats may be added. + @param identity_client holds the identity implementation to use for + authentication. If this is None, no authentication will be possible. + @param authorizer is used to check whether an authenticated user is + allowed to perform operations. If it is None, it will use + a ClosedAuthorizer. + The identity parameter passed to authorizer.allow will + always have been obtained from a call to + IdentityClient.declared_identity. + @param ops_store used to persistently store the association of + multi-op entities with their associated operations + when oven.macaroon is called with multiple operations. + @param location holds the location to use when creating new macaroons. + ''' + + if checker is None: + checker = checkers.Checker() + root_keystore_for_ops = None + if root_key_store is not None: + def root_keystore_for_ops(ops): + return root_key_store + + oven = Oven(key=key, + location=location, + locator=locator, + namespace=checker.namespace(), + root_keystore_for_ops=root_keystore_for_ops, + ops_store=ops_store) + self._oven = oven + + self._checker = Checker(checker=checker, authorizer=authorizer, + identity_client=identity_client, + macaroon_opstore=oven) + + @property + def oven(self): + return self._oven + + @property + def checker(self): + return self._checker diff --git a/macaroonbakery/bakery/_checker.py b/macaroonbakery/bakery/_checker.py new file mode 100644 index 0000000..88560cc --- /dev/null +++ b/macaroonbakery/bakery/_checker.py @@ -0,0 +1,418 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +from collections import namedtuple +from threading import Lock + +from ._authorizer import ClosedAuthorizer +from ._identity import NoIdentities +from ._error import ( + AuthInitError, + VerificationError, + IdentityError, + DischargeRequiredError, + PermissionDenied, +) +import macaroonbakery.checkers as checkers +import pyrfc3339 + + +class Op(namedtuple('Op', 'entity, action')): + ''' Op holds an entity and action to be authorized on that entity. + entity string holds the name of the entity to be authorized. + + @param entity should not contain spaces and should + not start with the prefix "login" or "multi-" (conventionally, + entity names will be prefixed with the entity type followed + by a hyphen. + @param action string holds the action to perform on the entity, + such as "read" or "delete". It is up to the service using a checker + to define a set of operations and keep them consistent over time. + ''' + + +# LOGIN_OP represents a login (authentication) operation. +# A macaroon that is associated with this operation generally +# carries authentication information with it. +LOGIN_OP = Op(entity='login', action='login') + + +class Checker(object): + '''Checker implements an authentication and authorization checker. + + It uses macaroons as authorization tokens but it is not itself responsible + for creating the macaroons + See the Oven type (TODO) for one way of doing that. + ''' + def __init__(self, checker=checkers.Checker(), + authorizer=ClosedAuthorizer(), + identity_client=None, + macaroon_opstore=None): + ''' + :param checker: a first party checker implementing a + :param authorizer (Authorizer): used to check whether an authenticated + user is allowed to perform operations. + The identity parameter passed to authorizer.allow will always have been + obtained from a call to identity_client.declared_identity. + :param identity_client (IdentityClient) used for interactions with the + external identity service used for authentication. + If this is None, no authentication will be possible. + :param macaroon_opstore (object with new_macaroon and macaroon_ops + method): used to retrieve macaroon root keys and other associated + information. + ''' + self._first_party_caveat_checker = checker + self._authorizer = authorizer + if identity_client is None: + identity_client = NoIdentities() + self._identity_client = identity_client + self._macaroon_opstore = macaroon_opstore + + def auth(self, mss): + ''' Returns a new AuthChecker instance using the given macaroons to + inform authorization decisions. + @param mss: a list of macaroon lists. + ''' + return AuthChecker(parent=self, + macaroons=mss) + + def namespace(self): + ''' Returns the namespace of the first party checker. + ''' + return self._first_party_caveat_checker.namespace() + + +class AuthChecker(object): + '''Authorizes operations with respect to a user's request. + + The identity is authenticated only once, the first time any method + of the AuthChecker is called, using the context passed in then. + + To find out any declared identity without requiring a login, + use allow(ctx); to require authentication but no additional operations, + use allow(ctx, LOGIN_OP). + ''' + def __init__(self, parent, macaroons): + ''' + + :param parent (Checker): used to check first party caveats. + :param macaroons: a list of py macaroons + ''' + self._macaroons = macaroons + self._init_errors = [] + self._executed = False + self._identity = None + self._identity_caveats = [] + self.parent = parent + self._conditions = None + self._mutex = Lock() + + def _init(self, ctx): + with self._mutex: + if not self._executed: + self._init_once(ctx) + self._executed = True + + def _init_once(self, ctx): + self._auth_indexes = {} + self._conditions = [None] * len(self._macaroons) + for i, ms in enumerate(self._macaroons): + try: + ops, conditions = self.parent._macaroon_opstore.macaroon_ops(ms) + except VerificationError as e: + self._init_errors.append(str(e)) + continue + except Exception as exc: + raise AuthInitError(str(exc)) + + # It's a valid macaroon (in principle - we haven't checked first + # party caveats). + self._conditions[i] = conditions + is_login = False + for op in ops: + if op == LOGIN_OP: + # Don't associate the macaroon with the login operation + # until we've verified that it is valid below + is_login = True + else: + if op not in self._auth_indexes: + self._auth_indexes[op] = [] + self._auth_indexes[op].append(i) + if not is_login: + continue + # It's a login macaroon. Check the conditions now - + # all calls want to see the same authentication + # information so that callers have a consistent idea of + # the client's identity. + # + # If the conditions fail, we won't use the macaroon for + # identity, but we can still potentially use it for its + # other operations if the conditions succeed for those. + declared, err = self._check_conditions(ctx, LOGIN_OP, conditions) + if err is not None: + self._init_errors.append('cannot authorize login macaroon: ' + err) + continue + if self._identity is not None: + # We've already found a login macaroon so ignore this one + # for the purposes of identity. + continue + + try: + identity = self.parent._identity_client.declared_identity( + ctx, declared) + except IdentityError as exc: + self._init_errors.append( + 'cannot decode declared identity: {}'.format(exc.args[0])) + continue + if LOGIN_OP not in self._auth_indexes: + self._auth_indexes[LOGIN_OP] = [] + self._auth_indexes[LOGIN_OP].append(i) + self._identity = identity + + if self._identity is None: + # No identity yet, so try to get one based on the context. + try: + identity, cavs = self.parent.\ + _identity_client.identity_from_context(ctx) + except IdentityError: + self._init_errors.append('could not determine identity') + if cavs is None: + cavs = [] + self._identity, self._identity_caveats = identity, cavs + return None + + def allow(self, ctx, ops): + ''' Checks that the authorizer's request is authorized to + perform all the given operations. Note that allow does not check + first party caveats - if there is more than one macaroon that may + authorize the request, it will choose the first one that does + regardless. + + If all the operations are allowed, an AuthInfo is returned holding + details of the decision and any first party caveats that must be + checked before actually executing any operation. + + If operations include LOGIN_OP, the request should contain an + authentication macaroon proving the client's identity. Once an + authentication macaroon is chosen, it will be used for all other + authorization requests. + + If an operation was not allowed, an exception will be raised which may + be: + + - DischargeRequiredError holding the operations that remain to + be authorized in order to allow authorization to proceed + - PermissionDenied when no operations can be authorized and there's + no third party to discharge macaroons for. + + @param ctx AuthContext + @param ops an array of Op + :return: an AuthInfo object. + ''' + auth_info, _ = self.allow_any(ctx, ops) + return auth_info + + def allow_any(self, ctx, ops): + ''' like allow except that it will authorize as many of the + operations as possible without requiring any to be authorized. If all + the operations succeeded, the array will be nil. + + If any the operations failed, the returned error will be the same + that allow would return and each element in the returned slice will + hold whether its respective operation was allowed. + + If all the operations succeeded, the returned slice will be None. + + The returned AuthInfo will always be non-None. + + The LOGIN_OP operation is treated specially - it is always required if + present in ops. + @param ctx AuthContext + @param ops an array of Op + :return: an AuthInfo object and the auth used as an array of int. + ''' + authed, used = self._allow_any(ctx, ops) + return self._new_auth_info(used), authed + + def _new_auth_info(self, used): + info = AuthInfo(identity=self._identity, macaroons=[]) + for i, is_used in enumerate(used): + if is_used: + info.macaroons.append(self._macaroons[i]) + return info + + def _allow_any(self, ctx, ops): + self._init(ctx) + used = [False] * len(self._macaroons) + authed = [False] * len(ops) + num_authed = 0 + errors = [] + for i, op in enumerate(ops): + for mindex in self._auth_indexes.get(op, []): + _, err = self._check_conditions(ctx, op, + self._conditions[mindex]) + if err is not None: + errors.append(err) + continue + authed[i] = True + num_authed += 1 + used[mindex] = True + # Use the first authorized macaroon only. + break + if op == LOGIN_OP and not authed[i] and self._identity is not None: + # Allow LOGIN_OP when there's an authenticated user even + # when there's no macaroon that specifically authorizes it. + authed[i] = True + if self._identity is not None: + # We've authenticated as a user, so even if the operations didn't + # specifically require it, we add the login macaroon + # to the macaroons used. + # Note that the LOGIN_OP conditions have already been checked + # successfully in initOnceFunc so no need to check again. + # Note also that there may not be any macaroons if the + # identity client decided on an identity even with no + # macaroons. + for i in self._auth_indexes.get(LOGIN_OP, []): + used[i] = True + if num_authed == len(ops): + # All operations allowed. + return authed, used + # There are some unauthorized operations. + need = [] + need_index = [0] * (len(ops) - num_authed) + for i, ok in enumerate(authed): + if not ok: + need_index[len(need)] = i + need.append(ops[i]) + + # Try to authorize the operations + # even if we haven't got an authenticated user. + oks, caveats = self.parent._authorizer.authorize( + ctx, self._identity, need) + still_need = [] + for i, _ in enumerate(need): + if i < len(oks) and oks[i]: + authed[need_index[i]] = True + else: + still_need.append(ops[need_index[i]]) + if len(still_need) == 0 and len(caveats) == 0: + # No more ops need to be authenticated and + # no caveats to be discharged. + return authed, used + if self._identity is None and len(self._identity_caveats) > 0: + raise DischargeRequiredError( + msg='authentication required', + ops=[LOGIN_OP], + cavs=self._identity_caveats) + if caveats is None or len(caveats) == 0: + all_errors = [] + all_errors.extend(self._init_errors) + all_errors.extend(errors) + err = '' + if len(all_errors) > 0: + err = all_errors[0] + raise PermissionDenied(err) + raise DischargeRequiredError( + msg='some operations have extra caveats', ops=ops, cavs=caveats) + + def allow_capability(self, ctx, ops): + '''Checks that the user is allowed to perform all the + given operations. If not, a discharge error will be raised. + If allow_capability succeeds, it returns a list of first party caveat + conditions that must be applied to any macaroon granting capability + to execute the operations. Those caveat conditions will not + include any declarations contained in login macaroons - the + caller must be careful not to mint a macaroon associated + with the LOGIN_OP operation unless they add the expected + declaration caveat too - in general, clients should not create + capabilities that grant LOGIN_OP rights. + + The operations must include at least one non-LOGIN_OP operation. + ''' + nops = 0 + for op in ops: + if op != LOGIN_OP: + nops += 1 + if nops == 0: + raise ValueError('no non-login operations required in capability') + + _, used = self._allow_any(ctx, ops) + squasher = _CaveatSquasher() + for i, is_used in enumerate(used): + if not is_used: + continue + for cond in self._conditions[i]: + squasher.add(cond) + return squasher.final() + + def _check_conditions(self, ctx, op, conds): + declared = checkers.infer_declared_from_conditions( + conds, + self.parent.namespace()) + ctx = checkers.context_with_operations(ctx, [op.action]) + ctx = checkers.context_with_declared(ctx, declared) + for cond in conds: + err = self.parent._first_party_caveat_checker.\ + check_first_party_caveat(ctx, cond) + if err is not None: + return None, err + return declared, None + + +class AuthInfo(namedtuple('AuthInfo', 'identity macaroons')): + '''AuthInfo information about an authorization decision. + + @param identity: holds information on the authenticated user as + returned identity_client. It may be None after a successful + authorization if LOGIN_OP access was not required. + + @param macaroons: holds all the macaroons that were used for the + authorization. Macaroons that were invalid or unnecessary are + not included. + ''' + + +class _CaveatSquasher(object): + ''' Rationalizes first party caveats created for a capability by: + - including only the earliest time-before caveat. + - excluding allow and deny caveats (operations are checked by + virtue of the operations associated with the macaroon). + - removing declared caveats. + - removing duplicates. + ''' + def __init__(self, expiry=None, conds=None): + self._expiry = expiry + if conds is None: + conds = [] + self._conds = conds + + def add(self, cond): + if self._add(cond): + self._conds.append(cond) + + def _add(self, cond): + try: + cond, args = checkers.parse_caveat(cond) + except ValueError: + # Be safe - if we can't parse the caveat, just leave it there. + return True + + if cond == checkers.COND_TIME_BEFORE: + try: + et = pyrfc3339.parse(args, utc=True).replace(tzinfo=None) + except ValueError: + # Again, if it doesn't seem valid, leave it alone. + return True + if self._expiry is None or et <= self._expiry: + self._expiry = et + return False + elif cond in [checkers.COND_ALLOW, + checkers.COND_DENY, checkers.COND_DECLARED]: + return False + return True + + def final(self): + if self._expiry is not None: + self._conds.append( + checkers.time_before_caveat(self._expiry).condition) + # Make deterministic and eliminate duplicates. + return sorted(set(self._conds)) diff --git a/macaroonbakery/bakery/_codec.py b/macaroonbakery/bakery/_codec.py new file mode 100644 index 0000000..903e604 --- /dev/null +++ b/macaroonbakery/bakery/_codec.py @@ -0,0 +1,301 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +import base64 +import json + +from ._versions import (VERSION_1, VERSION_2, VERSION_3) +from ._third_party import legacy_namespace, ThirdPartyCaveatInfo +from ._keys import PublicKey +from ._error import VerificationError +import macaroonbakery.checkers as checkers +import nacl.public +import six + +_PUBLIC_KEY_PREFIX_LEN = 4 +_KEY_LEN = 32 +# version3CaveatMinLen holds an underestimate of the +# minimum length of a version 3 caveat. +_VERSION3_CAVEAT_MIN_LEN = 1 + 4 + 32 + 24 + 16 + 1 + + +def encode_caveat(condition, root_key, third_party_info, key, ns): + '''Encrypt a third-party caveat. + + The third_party_info key holds information about the + third party we're encrypting the caveat for; the key is the + public/private key pair of the party that's adding the caveat. + + The caveat will be encoded according to the version information + found in third_party_info. + + @param condition string + @param root_key bytes + @param third_party_info object + @param key nacl key + @param ns not used yet + @return bytes + ''' + if third_party_info.version == VERSION_1: + return _encode_caveat_v1(condition, root_key, + third_party_info.public_key, key) + if (third_party_info.version == VERSION_2 or + third_party_info.version == VERSION_3): + return _encode_caveat_v2_v3(third_party_info.version, condition, + root_key, third_party_info.public_key, + key, ns) + raise NotImplementedError('only bakery v1, v2, v3 supported') + + +def _encode_caveat_v1(condition, root_key, third_party_pub_key, key): + '''Create a JSON-encoded third-party caveat. + + The third_party_pub_key key represents the PublicKey of the third party + we're encrypting the caveat for; the key is the public/private key pair of + the party that's adding the caveat. + + @param condition string + @param root_key bytes + @param third_party_pub_key (PublicKey) + @param key (PrivateKey) + @return a base64 encoded bytes + ''' + plain_data = json.dumps({ + 'RootKey': base64.b64encode(root_key).decode('ascii'), + 'Condition': condition + }) + box = nacl.public.Box(key.key, third_party_pub_key.key) + + encrypted = box.encrypt(six.b(plain_data)) + nonce = encrypted[0:nacl.public.Box.NONCE_SIZE] + encrypted = encrypted[nacl.public.Box.NONCE_SIZE:] + return base64.b64encode(six.b(json.dumps({ + 'ThirdPartyPublicKey': str(third_party_pub_key), + 'FirstPartyPublicKey': str(key.public_key), + 'Nonce': base64.b64encode(nonce).decode('ascii'), + 'Id': base64.b64encode(encrypted).decode('ascii') + }))) + + +def _encode_caveat_v2_v3(version, condition, root_key, third_party_pub_key, + key, ns): + '''Create a version 2 or version 3 third-party caveat. + + The format has the following packed binary fields (note + that all fields up to and including the nonce are the same + as the v2 format): + + version 2 or 3 [1 byte] + first 4 bytes of third-party Curve25519 public key [4 bytes] + first-party Curve25519 public key [32 bytes] + nonce [24 bytes] + encrypted secret part [rest of message] + + The encrypted part encrypts the following fields + with box.Seal: + + version 2 or 3 [1 byte] + length of root key [n: uvarint] + root key [n bytes] + length of encoded namespace [n: uvarint] (Version 3 only) + encoded namespace [n bytes] (Version 3 only) + condition [rest of encrypted part] + ''' + ns_data = bytearray() + if version >= VERSION_3: + ns_data = ns.serialize_text() + data = bytearray() + data.append(version) + data.extend(third_party_pub_key.serialize(raw=True)[:_PUBLIC_KEY_PREFIX_LEN]) + data.extend(key.public_key.serialize(raw=True)[:]) + secret = _encode_secret_part_v2_v3(version, condition, root_key, ns_data) + box = nacl.public.Box(key.key, third_party_pub_key.key) + encrypted = box.encrypt(secret) + nonce = encrypted[0:nacl.public.Box.NONCE_SIZE] + encrypted = encrypted[nacl.public.Box.NONCE_SIZE:] + data.extend(nonce[:]) + data.extend(encrypted) + return bytes(data) + + +def _encode_secret_part_v2_v3(version, condition, root_key, ns): + '''Creates a version 2 or version 3 secret part of the third party + caveat. The returned data is not encrypted. + + The format has the following packed binary fields: + version 2 or 3 [1 byte] + root key length [n: uvarint] + root key [n bytes] + namespace length [n: uvarint] (v3 only) + namespace [n bytes] (v3 only) + predicate [rest of message] + ''' + data = bytearray() + data.append(version) + encode_uvarint(len(root_key), data) + data.extend(root_key) + if version >= VERSION_3: + encode_uvarint(len(ns), data) + data.extend(ns) + data.extend(condition.encode('utf-8')) + return bytes(data) + + +def decode_caveat(key, caveat): + '''Decode caveat by decrypting the encrypted part using key. + + @param key the nacl private key to decode. + @param caveat bytes. + @return ThirdPartyCaveatInfo + ''' + if len(caveat) == 0: + raise VerificationError('empty third party caveat') + + first = caveat[:1] + if first == b'e': + # 'e' will be the first byte if the caveatid is a base64 + # encoded JSON object. + return _decode_caveat_v1(key, caveat) + first_as_int = six.byte2int(first) + if (first_as_int == VERSION_2 or + first_as_int == VERSION_3): + if (len(caveat) < _VERSION3_CAVEAT_MIN_LEN + and first_as_int == VERSION_3): + # If it has the version 3 caveat tag and it's too short, it's + # almost certainly an id, not an encrypted payload. + raise VerificationError( + 'caveat id payload not provided for caveat id {}'.format( + caveat)) + return _decode_caveat_v2_v3(first_as_int, key, caveat) + raise VerificationError('unknown version for caveat') + + +def _decode_caveat_v1(key, caveat): + '''Decode a base64 encoded JSON id. + + @param key the nacl private key to decode. + @param caveat a base64 encoded JSON string. + ''' + + data = base64.b64decode(caveat).decode('utf-8') + wrapper = json.loads(data) + tp_public_key = nacl.public.PublicKey( + base64.b64decode(wrapper['ThirdPartyPublicKey'])) + if key.public_key.key != tp_public_key: + raise Exception('public key mismatch') # TODO + + if wrapper.get('FirstPartyPublicKey', None) is None: + raise Exception('target service public key not specified') + + # The encrypted string is base64 encoded in the JSON representation. + secret = base64.b64decode(wrapper.get('Id')) + nonce = base64.b64decode(wrapper.get('Nonce')) + + fp_public_key = nacl.public.PublicKey(base64.b64decode( + wrapper.get('FirstPartyPublicKey'))) + + box = nacl.public.Box(key.key, fp_public_key) + c = box.decrypt(secret, nonce) + record = json.loads(c.decode('utf-8')) + fp_key = nacl.public.PublicKey( + base64.b64decode(wrapper.get('FirstPartyPublicKey'))) + return ThirdPartyCaveatInfo( + condition=record.get('Condition'), + first_party_public_key=PublicKey(fp_key), + third_party_key_pair=key, + root_key=base64.b64decode(record.get('RootKey')), + caveat=caveat, + id=None, + version=VERSION_1, + namespace=legacy_namespace() + ) + + +def _decode_caveat_v2_v3(version, key, caveat): + '''Decodes a version 2 or version 3 caveat. + ''' + if (len(caveat) < 1 + _PUBLIC_KEY_PREFIX_LEN + + _KEY_LEN + nacl.public.Box.NONCE_SIZE + 16): + raise VerificationError('caveat id too short') + original_caveat = caveat + caveat = caveat[1:] # skip version (already checked) + + pk_prefix = caveat[:_PUBLIC_KEY_PREFIX_LEN] + caveat = caveat[_PUBLIC_KEY_PREFIX_LEN:] + if key.public_key.serialize(raw=True)[:_PUBLIC_KEY_PREFIX_LEN] != pk_prefix: + raise VerificationError('public key mismatch') + + first_party_pub = caveat[:_KEY_LEN] + caveat = caveat[_KEY_LEN:] + nonce = caveat[:nacl.public.Box.NONCE_SIZE] + caveat = caveat[nacl.public.Box.NONCE_SIZE:] + fp_public_key = nacl.public.PublicKey(first_party_pub) + box = nacl.public.Box(key.key, fp_public_key) + data = box.decrypt(caveat, nonce) + root_key, condition, ns = _decode_secret_part_v2_v3(version, data) + return ThirdPartyCaveatInfo( + condition=condition.decode('utf-8'), + first_party_public_key=PublicKey(fp_public_key), + third_party_key_pair=key, + root_key=root_key, + caveat=original_caveat, + version=version, + id=None, + namespace=ns + ) + + +def _decode_secret_part_v2_v3(version, data): + if len(data) < 1: + raise VerificationError('secret part too short') + got_version = six.byte2int(data[:1]) + data = data[1:] + if version != got_version: + raise VerificationError( + 'unexpected secret part version, got {} want {}'.format( + got_version, version)) + root_key_length, read = decode_uvarint(data) + data = data[read:] + root_key = data[:root_key_length] + data = data[root_key_length:] + if version >= VERSION_3: + namespace_length, read = decode_uvarint(data) + data = data[read:] + ns_data = data[:namespace_length] + data = data[namespace_length:] + ns = checkers.deserialize_namespace(ns_data) + else: + ns = legacy_namespace() + return root_key, data, ns + + +def encode_uvarint(n, data): + '''encodes integer into variable-length format into data.''' + if n < 0: + raise ValueError('only support positive integer') + while True: + this_byte = n & 127 + n >>= 7 + if n == 0: + data.append(this_byte) + break + data.append(this_byte | 128) + + +def decode_uvarint(data): + '''Decode a variable-length integer. + + Reads a sequence of unsigned integer byte and decodes them into an integer + in variable-length format and returns it and the length read. + ''' + n = 0 + shift = 0 + length = 0 + for b in data: + if not isinstance(b, int): + b = six.byte2int(b) + n |= (b & 0x7f) << shift + length += 1 + if (b & 0x80) == 0: + break + shift += 7 + return n, length diff --git a/macaroonbakery/bakery/_discharge.py b/macaroonbakery/bakery/_discharge.py new file mode 100644 index 0000000..32284b7 --- /dev/null +++ b/macaroonbakery/bakery/_discharge.py @@ -0,0 +1,244 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +import abc +from collections import namedtuple + +from ._error import ( + ThirdPartyCaveatCheckFailed, + CaveatNotRecognizedError, + VerificationError, +) +from ._codec import decode_caveat +from ._macaroon import ( + Macaroon, + ThirdPartyLocator, +) +from ._versions import VERSION_2 +from ._third_party import ThirdPartyCaveatInfo + +import macaroonbakery.checkers as checkers + +emptyContext = checkers.AuthContext() + + +def discharge_all(m, get_discharge, local_key=None): + '''Gathers discharge macaroons for all the third party caveats in m + (and any subsequent caveats required by those) using get_discharge to + acquire each discharge macaroon. + The local_key parameter may optionally hold the key of the client, in + which case it will be used to discharge any third party caveats with the + special location "local". In this case, the caveat itself must be "true". + This can be used be a server to ask a client to prove ownership of the + private key. + It returns a list of macaroon with m as the first element, followed by all + the discharge macaroons. + All the discharge macaroons will be bound to the primary macaroon. + + The get_discharge function is passed a context (AuthContext), + the caveat(pymacaroons.Caveat) to be discharged and encrypted_caveat (bytes) will be + passed the external caveat payload found in m, if any. + It should return a bakery.Macaroon object holding the discharge + macaroon for the third party caveat. + ''' + primary = m.macaroon + discharges = [primary] + + # cav holds the macaroon caveat that needs discharge. + # encrypted_caveat (bytes) holds encrypted caveat if it was held + # externally. + _NeedCaveat = namedtuple('_NeedCaveat', 'cav encrypted_caveat') + need = [] + + def add_caveats(m): + for cav in m.macaroon.caveats: + if cav.location is None or cav.location == '': + continue + encrypted_caveat = m.caveat_data.get(cav.caveat_id, None) + need.append( + _NeedCaveat(cav=cav, + encrypted_caveat=encrypted_caveat)) + add_caveats(m) + while len(need) > 0: + cav = need[0] + need = need[1:] + if cav.cav.location == 'local': + if local_key is None: + raise ThirdPartyCaveatCheckFailed( + 'found local third party caveat but no private key provided', + ) + # TODO use a small caveat id. + dm = discharge(ctx=emptyContext, + key=local_key, + checker=_LocalDischargeChecker(), + caveat=cav.encrypted_caveat, + id=cav.cav.caveat_id_bytes, + locator=_EmptyLocator()) + else: + dm = get_discharge(cav.cav, cav.encrypted_caveat) + # It doesn't matter that we're invalidating dm here because we're + # about to throw it away. + discharge_m = dm.macaroon + m = primary.prepare_for_request(discharge_m) + discharges.append(m) + add_caveats(dm) + return discharges + + +class ThirdPartyCaveatChecker(object): + ''' Defines an abstract class that's used to check third party caveats. + ''' + __metaclass__ = abc.ABCMeta + + @abc.abstractmethod + def check_third_party_caveat(self, ctx, info): + ''' If the caveat is valid, it returns optionally a slice of + extra caveats that will be added to the discharge macaroon. + If the caveat kind was not recognised, the checker should + raise a CaveatNotRecognized exception; if the check failed, + it should raise a ThirdPartyCaveatCheckFailed exception. + :param ctx (AuthContext) + :param info (ThirdPartyCaveatInfo) holds the information decoded from + a third party caveat id + :return: An array of extra caveats to be added to the discharge + macaroon. + ''' + raise NotImplementedError('check_third_party_caveat method must be ' + 'defined in subclass') + + +class _LocalDischargeChecker(ThirdPartyCaveatChecker): + def check_third_party_caveat(self, ctx, info): + if info.condition != 'true': + raise CaveatNotRecognizedError() + return [] + + +def discharge(ctx, id, caveat, key, checker, locator): + ''' Creates a macaroon to discharge a third party caveat. + + The given parameters specify the caveat and how it should be checked. + The condition implicit in the caveat is checked for validity using checker. + If it is valid, a new macaroon is returned which discharges the caveat. + The macaroon is created with a version derived from the version that was + used to encode the id. + + :param id: (bytes) holds the id to give to the discharge macaroon. + If Caveat is empty, then the id also holds the encrypted third party + caveat. + :param caveat: (bytes) holds the encrypted third party caveat. + If this is None, id will be used. + :param key: holds the key to use to decrypt the third party caveat + information and to encrypt any additional third party caveats returned by + the caveat checker. + :param checker: used to check the third party caveat, and may also return + further caveats to be added to the discharge macaroon. + :param locator: used to information on third parties referred to by third + party caveats returned by the Checker. + ''' + caveat_id_prefix = [] + if caveat is None: + # The caveat information is encoded in the id itself. + caveat = id + else: + # We've been given an explicit id, so when extra third party + # caveats are added, use that id as the prefix + # for any more ids. + caveat_id_prefix = id + cav_info = decode_caveat(key, caveat) + cav_info = ThirdPartyCaveatInfo( + condition=cav_info.condition, + first_party_public_key=cav_info.first_party_public_key, + third_party_key_pair=cav_info.third_party_key_pair, + root_key=cav_info.root_key, + caveat=cav_info.caveat, + version=cav_info.version, + id=id, + namespace=cav_info.namespace + ) + # Note that we don't check the error - we allow the + # third party checker to see even caveats that we can't + # understand. + try: + cond, arg = checkers.parse_caveat(cav_info.condition) + except ValueError as exc: + raise VerificationError(exc.args[0]) + + if cond == checkers.COND_NEED_DECLARED: + cav_info = cav_info._replace(condition=arg) + caveats = _check_need_declared(ctx, cav_info, checker) + else: + caveats = checker.check_third_party_caveat(ctx, cav_info) + + # Note that the discharge macaroon does not need to + # be stored persistently. Indeed, it would be a problem if + # we did, because then the macaroon could potentially be used + # for normal authorization with the third party. + m = Macaroon( + cav_info.root_key, + id, + '', + cav_info.version, + cav_info.namespace, + ) + m._caveat_id_prefix = caveat_id_prefix + if caveats is not None: + for cav in caveats: + m.add_caveat(cav, key, locator) + return m + + +def _check_need_declared(ctx, cav_info, checker): + arg = cav_info.condition + i = arg.find(' ') + if i <= 0: + raise VerificationError( + 'need-declared caveat requires an argument, got %q'.format(arg), + ) + need_declared = arg[0:i].split(',') + for d in need_declared: + if d == '': + raise VerificationError('need-declared caveat with empty required attribute') + if len(need_declared) == 0: + raise VerificationError('need-declared caveat with no required attributes') + cav_info = cav_info._replace(condition=arg[i + 1:]) + caveats = checker.check_third_party_caveat(ctx, cav_info) + declared = {} + for cav in caveats: + if cav.location is not None and cav.location != '': + continue + # Note that we ignore the error. We allow the service to + # generate caveats that we don't understand here. + try: + cond, arg = checkers.parse_caveat(cav.condition) + except ValueError: + continue + if cond != checkers.COND_DECLARED: + continue + parts = arg.split() + if len(parts) != 2: + raise VerificationError('declared caveat has no value') + declared[parts[0]] = True + # Add empty declarations for everything mentioned in need-declared + # that was not actually declared. + for d in need_declared: + if not declared.get(d, False): + caveats.append(checkers.declared_caveat(d, '')) + return caveats + + +class _EmptyLocator(ThirdPartyLocator): + def third_party_info(self, loc): + return None + + +def local_third_party_caveat(key, version): + ''' Returns a third-party caveat that, when added to a macaroon with + add_caveat, results in a caveat with the location "local", encrypted with + the given PublicKey. + This can be automatically discharged by discharge_all passing a local key. + ''' + if version >= VERSION_2: + loc = 'local {} {}'.format(version, key) + else: + loc = 'local {}'.format(key) + return checkers.Caveat(location=loc, condition='') diff --git a/macaroonbakery/bakery/_error.py b/macaroonbakery/bakery/_error.py new file mode 100644 index 0000000..b403569 --- /dev/null +++ b/macaroonbakery/bakery/_error.py @@ -0,0 +1,77 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. + + +class DischargeRequiredError(Exception): + ''' Raised by checker when authorization has failed and a discharged + macaroon might fix it. + + A caller should grant the user the ability to authorize by minting a + macaroon associated with Ops (see MacaroonStore.MacaroonIdInfo for + how the associated operations are retrieved) and adding Caveats. If + the user succeeds in discharging the caveats, the authorization will + be granted. + ''' + def __init__(self, msg, ops, cavs): + ''' + :param msg: holds some reason why the authorization was denied. + :param ops: holds all the operations that were not authorized. + If ops contains a single LOGIN_OP member, the macaroon + should be treated as an login token. Login tokens (also + known as authentication macaroons) usually have a longer + life span than other macaroons. + :param cavs: holds the caveats that must be added to macaroons that + authorize the above operations. + ''' + super(DischargeRequiredError, self).__init__(msg) + self._ops = ops + self._cavs = cavs + + def ops(self): + return self._ops + + def cavs(self): + return self._cavs + + +class PermissionDenied(Exception): + '''Raised from AuthChecker when permission has been denied. + ''' + pass + + +class CaveatNotRecognizedError(Exception): + '''Containing the cause of errors returned from caveat checkers when the + caveat was not recognized. + ''' + pass + + +class VerificationError(Exception): + '''Raised to signify that an error is because of a verification failure + rather than because verification could not be done.''' + pass + + +class AuthInitError(Exception): + '''Raised if AuthChecker cannot be initialized properly.''' + pass + + +class IdentityError(Exception): + ''' Raised from IdentityClient.declared_identity when an error occurs. + ''' + pass + + +class ThirdPartyCaveatCheckFailed(Exception): + ''' Raised from ThirdPartyCaveatChecker.check_third_party when check fails. + ''' + pass + + +class ThirdPartyInfoNotFound(Exception): + ''' Raised from implementation of ThirdPartyLocator.third_party_info when + the info cannot be found. + ''' + pass diff --git a/macaroonbakery/bakery/_identity.py b/macaroonbakery/bakery/_identity.py new file mode 100644 index 0000000..4389cd9 --- /dev/null +++ b/macaroonbakery/bakery/_identity.py @@ -0,0 +1,126 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +import abc + +from ._error import IdentityError + + +class Identity(object): + ''' Holds identity information declared in a first party caveat added when + discharging a third party caveat. + ''' + __metaclass__ = abc.ABCMeta + + @abc.abstractmethod + def id(self): + ''' Returns the id of the user. + + May be an opaque blob with no human meaning. An id is only considered + to be unique with a given domain. + :return string + ''' + raise NotImplementedError('id method must be defined in subclass') + + @abc.abstractmethod + def domain(self): + '''Return the domain of the user. + + This will be empty if the user was authenticated + directly with the identity provider. + :return string + ''' + raise NotImplementedError('domain method must be defined in subclass') + + +class ACLIdentity(Identity): + ''' ACLIdentity may be implemented by Identity implementations + to report group membership information. + ''' + __metaclass__ = abc.ABCMeta + + @abc.abstractmethod + def allow(self, ctx, acls): + ''' reports whether the user should be allowed to access + any of the users or groups in the given acl list. + :param ctx(AuthContext) is the context of the authorization request. + :param acls array of string acl + :return boolean + ''' + raise NotImplementedError('allow method must be defined in subclass') + + +class SimpleIdentity(ACLIdentity): + ''' A simple form of identity where the user is represented by a string. + ''' + def __init__(self, user): + self._identity = user + + def domain(self): + ''' A simple identity has no domain. + ''' + return '' + + def id(self): + '''Return the user name as the id. + ''' + return self._identity + + def allow(self, ctx, acls): + '''Allow access to any ACL members that was equal to the user name. + + That is, some user u is considered a member of group u and no other. + ''' + for acl in acls: + if self._identity == acl: + return True + return False + + +class IdentityClient(object): + ''' Represents an abstract identity manager. User identities can be based + on local informaton (for example HTTP basic auth) or by reference to an + external trusted third party (an identity manager). + ''' + __metaclass__ = abc.ABCMeta + + @abc.abstractmethod + def identity_from_context(self, ctx): + ''' Returns the identity based on information in the context. + + If it cannot determine the identity based on the context, then it + should return a set of caveats containing a third party caveat that, + when discharged, can be used to obtain the identity with + declared_identity. + + It should only raise an error if it cannot check the identity + (for example because of a database access error) - it's + OK to return all zero values when there's + no identity found and no third party to address caveats to. + @param ctx an AuthContext + :return: an Identity and array of caveats + ''' + raise NotImplementedError('identity_from_context method must be ' + 'defined in subclass') + + @abc.abstractmethod + def declared_identity(self, ctx, declared): + '''Parses the identity declaration from the given declared attributes. + + TODO take the set of first party caveat conditions instead? + @param ctx (AuthContext) + @param declared (dict of string/string) + :return: an Identity + ''' + raise NotImplementedError('declared_identity method must be ' + 'defined in subclass') + + +class NoIdentities(IdentityClient): + ''' Defines the null identity provider - it never returns any identities. + ''' + + def identity_from_context(self, ctx): + return None, None + + def declared_identity(self, ctx, declared): + raise IdentityError('no identity declared or possible') diff --git a/macaroonbakery/bakery/_internal/__init__.py b/macaroonbakery/bakery/_internal/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/macaroonbakery/bakery/_internal/id_pb2.py b/macaroonbakery/bakery/_internal/id_pb2.py new file mode 100644 index 0000000..0fd54c0 --- /dev/null +++ b/macaroonbakery/bakery/_internal/id_pb2.py @@ -0,0 +1,132 @@ +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: macaroonbakery/internal/id.proto + +import sys +_b=sys.version_info[0]<3 and (lambda x:x) or (lambda x:x.encode('latin1')) +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from google.protobuf import reflection as _reflection +from google.protobuf import symbol_database as _symbol_database +from google.protobuf import descriptor_pb2 +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor.FileDescriptor( + name='macaroonbakery/internal/id.proto', + package='', + syntax='proto3', + serialized_pb=_b('\n macaroonbakery/internal/id.proto\"@\n\nMacaroonId\x12\r\n\x05nonce\x18\x01 \x01(\x0c\x12\x11\n\tstorageId\x18\x02 \x01(\x0c\x12\x10\n\x03ops\x18\x03 \x03(\x0b\x32\x03.Op\"%\n\x02Op\x12\x0e\n\x06\x65ntity\x18\x01 \x01(\t\x12\x0f\n\x07\x61\x63tions\x18\x02 \x03(\tB\x0cZ\nmacaroonpbb\x06proto3') +) + + + + +_MACAROONID = _descriptor.Descriptor( + name='MacaroonId', + full_name='MacaroonId', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='nonce', full_name='MacaroonId.nonce', index=0, + number=1, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None), + _descriptor.FieldDescriptor( + name='storageId', full_name='MacaroonId.storageId', index=1, + number=2, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None), + _descriptor.FieldDescriptor( + name='ops', full_name='MacaroonId.ops', index=2, + number=3, type=11, cpp_type=10, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=36, + serialized_end=100, +) + + +_OP = _descriptor.Descriptor( + name='Op', + full_name='Op', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='entity', full_name='Op.entity', index=0, + number=1, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None), + _descriptor.FieldDescriptor( + name='actions', full_name='Op.actions', index=1, + number=2, type=9, cpp_type=9, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=102, + serialized_end=139, +) + +_MACAROONID.fields_by_name['ops'].message_type = _OP +DESCRIPTOR.message_types_by_name['MacaroonId'] = _MACAROONID +DESCRIPTOR.message_types_by_name['Op'] = _OP +_sym_db.RegisterFileDescriptor(DESCRIPTOR) + +MacaroonId = _reflection.GeneratedProtocolMessageType('MacaroonId', (_message.Message,), dict( + DESCRIPTOR = _MACAROONID, + __module__ = 'macaroonbakery.internal.id_pb2' + # @@protoc_insertion_point(class_scope:MacaroonId) + )) +_sym_db.RegisterMessage(MacaroonId) + +Op = _reflection.GeneratedProtocolMessageType('Op', (_message.Message,), dict( + DESCRIPTOR = _OP, + __module__ = 'macaroonbakery.internal.id_pb2' + # @@protoc_insertion_point(class_scope:Op) + )) +_sym_db.RegisterMessage(Op) + + +DESCRIPTOR.has_options = True +DESCRIPTOR._options = _descriptor._ParseOptions(descriptor_pb2.FileOptions(), _b('Z\nmacaroonpb')) +# @@protoc_insertion_point(module_scope) diff --git a/macaroonbakery/bakery/_keys.py b/macaroonbakery/bakery/_keys.py new file mode 100644 index 0000000..1da5f05 --- /dev/null +++ b/macaroonbakery/bakery/_keys.py @@ -0,0 +1,100 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. + +import nacl.public + + +class PrivateKey(object): + ''' A private key used by the bakery to encrypt and decrypt + third party caveats. + Internally, it is a 256-bit Ed25519 private key. + ''' + def __init__(self, key): + self._key = key + + @property + def key(self): + ''' Internal nacl key representation. + ''' + return self._key + + @property + def public_key(self): + ''' + :return: the PublicKey associated with the private key. + ''' + return PublicKey(self._key.public_key) + + @classmethod + def deserialize(cls, serialized): + ''' Create a PrivateKey from a base64 encoded bytes. + :return: a PrivateKey + ''' + return PrivateKey( + nacl.public.PrivateKey(serialized, + encoder=nacl.encoding.Base64Encoder)) + + def serialize(self, raw=False): + '''Encode the private part of the key in a base64 format by default, + but when raw is True it will return hex encoded bytes. + @return: bytes + ''' + if raw: + return self._key.encode() + return self._key.encode(nacl.encoding.Base64Encoder) + + def __str__(self): + '''Return the private part of the key key as a base64-encoded string''' + return self.serialize().decode('utf-8') + + def __eq__(self, other): + return self.key == other.key + + +class PublicKey(object): + ''' A public key used by the bakery to encrypt third party caveats. + + Every discharger is associated with a public key which is used to + encrypt third party caveat ids addressed to that discharger. + Internally, it is a 256 bit Ed25519 public key. + ''' + def __init__(self, key): + self._key = key + + @property + def key(self): + ''' Internal nacl key representation. + ''' + return self._key + + def serialize(self, raw=False): + '''Encode the private part of the key in a base64 format by default, + but when raw is True it will return hex encoded bytes. + @return: bytes + ''' + if raw: + return self._key.encode() + return self._key.encode(nacl.encoding.Base64Encoder) + + def __str__(self): + '''Return the key as a base64-encoded string''' + return self.serialize().decode('utf-8') + + @classmethod + def deserialize(cls, serialized): + ''' Create a PublicKey from a base64 encoded bytes. + :return: a PublicKey + ''' + return PublicKey( + nacl.public.PublicKey(serialized, + encoder=nacl.encoding.Base64Encoder)) + + def __eq__(self, other): + return self.key == other.key + + +def generate_key(): + '''GenerateKey generates a new PrivateKey. + :return: a PrivateKey + ''' + return PrivateKey(nacl.public.PrivateKey.generate()) diff --git a/macaroonbakery/bakery/_macaroon.py b/macaroonbakery/bakery/_macaroon.py new file mode 100644 index 0000000..1438ab1 --- /dev/null +++ b/macaroonbakery/bakery/_macaroon.py @@ -0,0 +1,430 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +import abc +import base64 +import json +import logging +import os + +import macaroonbakery.checkers as checkers +import pymacaroons +from macaroonbakery._utils import b64decode +from pymacaroons.serializers import json_serializer +from ._versions import ( + LATEST_VERSION, + VERSION_0, + VERSION_1, + VERSION_2, + VERSION_3, +) +from ._error import ( + ThirdPartyInfoNotFound, +) +from ._codec import ( + encode_uvarint, + encode_caveat, +) +from ._keys import PublicKey +from ._third_party import ( + legacy_namespace, + ThirdPartyInfo, +) + +log = logging.getLogger(__name__) + + +class Macaroon(object): + '''Represent an undischarged macaroon along with its first + party caveat namespace and associated third party caveat information + which should be passed to the third party when discharging a caveat. + ''' + + def __init__(self, root_key, id, location=None, + version=LATEST_VERSION, namespace=None): + '''Creates a new macaroon with the given root key, id and location. + + If the version is more than the latest known version, + the latest known version will be used. The namespace should hold the + namespace of the service that is creating the macaroon. + @param root_key bytes or string + @param id bytes or string + @param location bytes or string + @param version the bakery version. + @param namespace is that of the service creating it + ''' + if version > LATEST_VERSION: + log.info('use last known version:{} instead of: {}'.format( + LATEST_VERSION, version + )) + version = LATEST_VERSION + # m holds the underlying macaroon. + self._macaroon = pymacaroons.Macaroon( + location=location, key=root_key, identifier=id, + version=macaroon_version(version)) + # version holds the version of the macaroon. + self._version = version + self._caveat_data = {} + if namespace is None: + namespace = checkers.Namespace() + self._namespace = namespace + self._caveat_id_prefix = bytearray() + + @property + def macaroon(self): + ''' Return the underlying macaroon. + ''' + return self._macaroon + + @property + def version(self): + return self._version + + @property + def namespace(self): + return self._namespace + + @property + def caveat_data(self): + return self._caveat_data + + def add_caveat(self, cav, key=None, loc=None): + '''Add a caveat to the macaroon. + + It encrypts it using the given key pair + and by looking up the location using the given locator. + As a special case, if the caveat's Location field has the prefix + "local " the caveat is added as a client self-discharge caveat using + the public key base64-encoded in the rest of the location. In this + case, the Condition field must be empty. The resulting third-party + caveat will encode the condition "true" encrypted with that public + key. + + @param cav the checkers.Caveat to be added. + @param key the public key to encrypt third party caveat. + @param loc locator to find information on third parties when adding + third party caveats. It is expected to have a third_party_info method + that will be called with a location string and should return a + ThirdPartyInfo instance holding the requested information. + ''' + if cav.location is None: + self._macaroon.add_first_party_caveat( + self.namespace.resolve_caveat(cav).condition) + return + if key is None: + raise ValueError( + 'no private key to encrypt third party caveat') + local_info = _parse_local_location(cav.location) + if local_info is not None: + if cav.condition: + raise ValueError( + 'cannot specify caveat condition in ' + 'local third-party caveat') + info = local_info + cav = checkers.Caveat(location='local', condition='true') + else: + if loc is None: + raise ValueError( + 'no locator when adding third party caveat') + info = loc.third_party_info(cav.location) + + root_key = os.urandom(24) + + # Use the least supported version to encode the caveat. + if self._version < info.version: + info = ThirdPartyInfo( + version=self._version, + public_key=info.public_key, + ) + + caveat_info = encode_caveat( + cav.condition, root_key, info, key, self._namespace) + if info.version < VERSION_3: + # We're encoding for an earlier client or third party which does + # not understand bundled caveat info, so use the encoded + # caveat information as the caveat id. + id = caveat_info + else: + id = self._new_caveat_id(self._caveat_id_prefix) + self._caveat_data[id] = caveat_info + + self._macaroon.add_third_party_caveat(cav.location, root_key, id) + + def add_caveats(self, cavs, key, loc): + '''Add an array of caveats to the macaroon. + + This method does not mutate the current object. + @param cavs arrary of caveats. + @param key the PublicKey to encrypt third party caveat. + @param loc locator to find the location object that has a method + third_party_info. + ''' + if cavs is None: + return + for cav in cavs: + self.add_caveat(cav, key, loc) + + def serialize_json(self): + '''Return a string holding the macaroon data in JSON format. + @return a string holding the macaroon data in JSON format + ''' + return json.dumps(self.to_dict()) + + def to_dict(self): + '''Return a dict representation of the macaroon data in JSON format. + @return a dict + ''' + if self.version < VERSION_3: + if len(self._caveat_data) > 0: + raise ValueError('cannot serialize pre-version3 macaroon with ' + 'external caveat data') + return json.loads(self._macaroon.serialize( + json_serializer.JsonSerializer())) + serialized = { + 'm': json.loads(self._macaroon.serialize( + json_serializer.JsonSerializer())), + 'v': self._version, + } + if self._namespace is not None: + serialized['ns'] = self._namespace.serialize_text().decode('utf-8') + caveat_data = {} + for id in self._caveat_data: + key = base64.b64encode(id).decode('utf-8') + value = base64.b64encode(self._caveat_data[id]).decode('utf-8') + caveat_data[key] = value + if len(caveat_data) > 0: + serialized['cdata'] = caveat_data + return serialized + + @classmethod + def from_dict(cls, json_dict): + '''Return a macaroon obtained from the given dictionary as + deserialized from JSON. + @param json_dict The deserialized JSON object. + ''' + json_macaroon = json_dict.get('m') + if json_macaroon is None: + # Try the v1 format if we don't have a macaroon field. + m = pymacaroons.Macaroon.deserialize( + json.dumps(json_dict), json_serializer.JsonSerializer()) + macaroon = Macaroon(root_key=None, id=None, + namespace=legacy_namespace(), + version=_bakery_version(m.version)) + macaroon._macaroon = m + return macaroon + + version = json_dict.get('v', None) + if version is None: + raise ValueError('no version specified') + if (version < VERSION_3 or + version > LATEST_VERSION): + raise ValueError('unknown bakery version {}'.format(version)) + m = pymacaroons.Macaroon.deserialize(json.dumps(json_macaroon), + json_serializer.JsonSerializer()) + if m.version != macaroon_version(version): + raise ValueError( + 'underlying macaroon has inconsistent version; ' + 'got {} want {}'.format(m.version, macaroon_version(version))) + namespace = checkers.deserialize_namespace(json_dict.get('ns')) + cdata = json_dict.get('cdata', {}) + caveat_data = {} + for id64 in cdata: + id = b64decode(id64) + data = b64decode(cdata[id64]) + caveat_data[id] = data + macaroon = Macaroon(root_key=None, id=None, + namespace=namespace, + version=version) + macaroon._caveat_data = caveat_data + macaroon._macaroon = m + return macaroon + + @classmethod + def deserialize_json(cls, serialized_json): + '''Return a macaroon deserialized from a string + @param serialized_json The string to decode {str} + @return {Macaroon} + ''' + serialized = json.loads(serialized_json) + return Macaroon.from_dict(serialized) + + def _new_caveat_id(self, base): + '''Return a third party caveat id + + This does not duplicate any third party caveat ids already inside + macaroon. If base is non-empty, it is used as the id prefix. + + @param base bytes + @return bytes + ''' + id = bytearray() + if len(base) > 0: + id.extend(base) + else: + # Add a version byte to the caveat id. Technically + # this is unnecessary as the caveat-decoding logic + # that looks at versions should never see this id, + # but if the caveat payload isn't provided with the + # payload, having this version gives a strong indication + # that the payload has been omitted so we can produce + # a better error for the user. + id.append(VERSION_3) + + # Iterate through integers looking for one that isn't already used, + # starting from n so that if everyone is using this same algorithm, + # we'll only perform one iteration. + i = len(self._caveat_data) + caveats = self._macaroon.caveats + while True: + # We append a varint to the end of the id and assume that + # any client that's created the id that we're using as a base + # is using similar conventions - in the worst case they might + # end up with a duplicate third party caveat id and thus create + # a macaroon that cannot be discharged. + temp = id[:] + encode_uvarint(i, temp) + found = False + for cav in caveats: + if (cav.verification_key_id is not None + and cav.caveat_id == temp): + found = True + break + if not found: + return bytes(temp) + i += 1 + + def first_party_caveats(self): + '''Return the first party caveats from this macaroon. + + @return the first party caveats from this macaroon as pymacaroons + caveats. + ''' + return self._macaroon.first_party_caveats() + + def third_party_caveats(self): + '''Return the third party caveats. + + @return the third party caveats as pymacaroons caveats. + ''' + return self._macaroon.third_party_caveats() + + def copy(self): + ''' Returns a copy of the macaroon. Note that the the new + macaroon's namespace still points to the same underlying Namespace - + copying the macaroon does not make a copy of the namespace. + :return a Macaroon + ''' + m1 = Macaroon(None, None, version=self._version, + namespace=self._namespace) + m1._macaroon = self._macaroon.copy() + m1._caveat_data = self._caveat_data.copy() + return m1 + + +def macaroon_version(bakery_version): + '''Return the macaroon version given the bakery version. + + @param bakery_version the bakery version + @return macaroon_version the derived macaroon version + ''' + if bakery_version in [VERSION_0, VERSION_1]: + return pymacaroons.MACAROON_V1 + return pymacaroons.MACAROON_V2 + + +class ThirdPartyLocator(object): + '''Used to find information on third party discharge services. + ''' + __metaclass__ = abc.ABCMeta + + @abc.abstractmethod + def third_party_info(self, loc): + '''Return information on the third party at the given location. + @param loc string + @return: a ThirdPartyInfo + @raise: ThirdPartyInfoNotFound + ''' + raise NotImplementedError('third_party_info method must be defined in ' + 'subclass') + + +class ThirdPartyStore(ThirdPartyLocator): + ''' Implements a simple in memory ThirdPartyLocator. + ''' + def __init__(self): + self._store = {} + + def third_party_info(self, loc): + info = self._store.get(loc.rstrip('/')) + if info is None: + raise ThirdPartyInfoNotFound( + 'cannot retrieve the info for location {}'.format(loc)) + return info + + def add_info(self, loc, info): + '''Associates the given information with the given location. + It will ignore any trailing slash. + @param loc the location as string + @param info (ThirdPartyInfo) to store for this location. + ''' + self._store[loc.rstrip('/')] = info + + +def _parse_local_location(loc): + '''Parse a local caveat location as generated by LocalThirdPartyCaveat. + + This is of the form: + + local + + where is the bakery version of the client that we're + adding the local caveat for. + + It returns None if the location does not represent a local + caveat location. + @return a ThirdPartyInfo. + ''' + if not (loc.startswith('local ')): + return None + v = VERSION_1 + fields = loc.split() + fields = fields[1:] # Skip 'local' + if len(fields) == 2: + try: + v = int(fields[0]) + except ValueError: + return None + fields = fields[1:] + if len(fields) == 1: + key = PublicKey.deserialize(fields[0]) + return ThirdPartyInfo(public_key=key, version=v) + return None + + +def _bakery_version(v): + # bakery_version returns a bakery version that corresponds to + # the macaroon version v. It is necessarily approximate because + # several bakery versions can correspond to a single macaroon + # version, so it's only of use when decoding legacy formats + # + # It will raise a ValueError if it doesn't recognize the version. + if v == pymacaroons.MACAROON_V1: + # Use version 1 because we don't know of any existing + # version 0 clients. + return VERSION_1 + elif v == pymacaroons.MACAROON_V2: + # Note that this could also correspond to Version 3, but + # this logic is explicitly for legacy versions. + return VERSION_2 + else: + raise ValueError('unknown macaroon version when deserializing legacy ' + 'bakery macaroon; got {}'.format(v)) + + +class MacaroonJSONEncoder(json.JSONEncoder): + def encode(self, m): + return m.serialize_json() + + +class MacaroonJSONDecoder(json.JSONDecoder): + def decode(self, s, _w=json.decoder.WHITESPACE.match): + return Macaroon.deserialize_json(s) diff --git a/macaroonbakery/bakery/_oven.py b/macaroonbakery/bakery/_oven.py new file mode 100644 index 0000000..d0a2a23 --- /dev/null +++ b/macaroonbakery/bakery/_oven.py @@ -0,0 +1,289 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. + +import base64 +import hashlib +import itertools +import os + +import google +from ._checker import (Op, LOGIN_OP) +from ._store import MemoryKeyStore +from ._error import VerificationError +from ._versions import ( + VERSION_2, + VERSION_3, + LATEST_VERSION, +) +from ._macaroon import ( + Macaroon, + macaroon_version, +) + +import macaroonbakery.checkers as checkers +import six +from macaroonbakery._utils import ( + raw_urlsafe_b64encode, + b64decode, +) +from ._internal import id_pb2 +from pymacaroons import MACAROON_V2, Verifier + + +class Oven: + ''' Oven bakes macaroons. They emerge sweet and delicious and ready for use + in a Checker. + + All macaroons are associated with one or more operations (see + the Op type) which define the capabilities of the macaroon. + + There is one special operation, "login" (defined by LOGIN_OP) which grants + the capability to speak for a particular user. + The login capability will never be mixed with other capabilities. + + It is up to the caller to decide on semantics for other operations. + ''' + + def __init__(self, key=None, location=None, locator=None, namespace=None, + root_keystore_for_ops=None, ops_store=None): + ''' + @param namespace holds the namespace to use when adding first party + caveats. + @param root_keystore_for_ops a function that will give the macaroon + storage to be used for root keys associated with macaroons created + with macaroon. + @param ops_store object is used to persistently store the association + of multi-op entities with their associated operations when macaroon is + called with multiple operations. + When this is in use, operation entities with the prefix "multi-" are + reserved - a "multi-"-prefixed entity represents a set of operations + stored in the OpsStore. + @param key holds the private nacl key pair used to encrypt third party + caveats. If it is None, no third party caveats can be created. + @param location string holds the location that will be associated with + new macaroons (as returned by Macaroon.Location). + @param locator is used to find out information on third parties when + adding third party caveats. If this is None, no non-local third + party caveats can be added. + ''' + self.key = key + self.location = location + self.locator = locator + if namespace is None: + namespace = checkers.Checker().namespace() + self.namespace = namespace + self.ops_store = ops_store + self.root_keystore_for_ops = root_keystore_for_ops + if root_keystore_for_ops is None: + my_store = MemoryKeyStore() + self.root_keystore_for_ops = lambda x: my_store + + def macaroon(self, version, expiry, caveats, ops): + ''' Takes a macaroon with the given version from the oven, + associates it with the given operations and attaches the given caveats. + There must be at least one operation specified. + The macaroon will expire at the given time - a time_before first party + caveat will be added with that time. + + @return: a new Macaroon object. + ''' + if len(ops) == 0: + raise ValueError('cannot mint a macaroon associated ' + 'with no operations') + + ops = canonical_ops(ops) + root_key, storage_id = self.root_keystore_for_ops(ops).root_key() + + id = self._new_macaroon_id(storage_id, expiry, ops) + + id_bytes = six.int2byte(LATEST_VERSION) + \ + id.SerializeToString() + + if macaroon_version(version) < MACAROON_V2: + # The old macaroon format required valid text for the macaroon id, + # so base64-encode it. + id_bytes = raw_urlsafe_b64encode(id_bytes) + + m = Macaroon( + root_key, + id_bytes, + self.location, + version, + self.namespace, + ) + m.add_caveat(checkers.time_before_caveat(expiry), self.key, + self.locator) + m.add_caveats(caveats, self.key, self.locator) + return m + + def _new_macaroon_id(self, storage_id, expiry, ops): + nonce = os.urandom(16) + if len(ops) == 1 or self.ops_store is None: + return id_pb2.MacaroonId( + nonce=nonce, + storageId=storage_id, + ops=_macaroon_id_ops(ops)) + # We've got several operations and a multi-op store, so use the store. + # TODO use the store only if the encoded macaroon id exceeds some size? + entity = self.ops_entity(ops) + self.ops_store.put_ops(entity, expiry, ops) + return id_pb2.MacaroonId( + nonce=nonce, + storageId=storage_id, + ops=[id_pb2.Op(entity=entity, actions=['*'])]) + + def ops_entity(self, ops): + ''' Returns a new multi-op entity name string that represents + all the given operations and caveats. It returns the same value + regardless of the ordering of the operations. It assumes that the + operations have been canonicalized and that there's at least one + operation. + + :param ops: + :return: string that represents all the given operations and caveats. + ''' + # Hash the operations, removing duplicates as we go. + hash_entity = hashlib.sha256() + for op in ops: + hash_entity.update('{}\n{}\n'.format( + op.action, op.entity).encode()) + hash_encoded = base64.urlsafe_b64encode(hash_entity.digest()) + return 'multi-' + hash_encoded.decode('utf-8').rstrip('=') + + def macaroon_ops(self, macaroons): + ''' This method makes the oven satisfy the MacaroonOpStore protocol + required by the Checker class. + + For macaroons minted with previous bakery versions, it always + returns a single LoginOp operation. + + :param macaroons: + :return: + ''' + if len(macaroons) == 0: + raise ValueError('no macaroons provided') + + storage_id, ops = _decode_macaroon_id(macaroons[0].identifier_bytes) + root_key = self.root_keystore_for_ops(ops).get(storage_id) + if root_key is None: + raise VerificationError( + 'macaroon key not found in storage') + v = Verifier() + conditions = [] + + def validator(condition): + # Verify the macaroon's signature only. Don't check any of the + # caveats yet but save them so that we can return them. + conditions.append(condition) + return True + v.satisfy_general(validator) + try: + v.verify(macaroons[0], root_key, macaroons[1:]) + except Exception as exc: + # Unfortunately pymacaroons doesn't control + # the set of exceptions that can be raised here. + # Possible candidates are: + # pymacaroons.exceptions.MacaroonUnmetCaveatException + # pymacaroons.exceptions.MacaroonInvalidSignatureException + # ValueError + # nacl.exceptions.CryptoError + # + # There may be others too, so just catch everything. + raise six.raise_from( + VerificationError('verification failed: {}'.format(str(exc))), + exc, + ) + + if (self.ops_store is not None + and len(ops) == 1 + and ops[0].entity.startswith('multi-')): + # It's a multi-op entity, so retrieve the actual operations + # it's associated with. + ops = self.ops_store.get_ops(ops[0].entity) + + return ops, conditions + + +def _decode_macaroon_id(id): + storage_id = b'' + base64_decoded = False + first = id[:1] + if first == b'A': + # The first byte is not a version number and it's 'A', which is the + # base64 encoding of the top 6 bits (all zero) of the version number 2 + # or 3, so we assume that it's the base64 encoding of a new-style + # macaroon id, so we base64 decode it. + # + # Note that old-style ids always start with an ASCII character >= 4 + # (> 32 in fact) so this logic won't be triggered for those. + try: + dec = b64decode(id.decode('utf-8')) + # Set the id only on success. + id = dec + base64_decoded = True + except: + # if it's a bad encoding, we'll get an error which is fine + pass + + # Trim any extraneous information from the id before retrieving + # it from storage, including the UUID that's added when + # creating macaroons to make all macaroons unique even if + # they're using the same root key. + first = six.byte2int(id[:1]) + if first == VERSION_2: + # Skip the UUID at the start of the id. + storage_id = id[1 + 16:] + if first == VERSION_3: + try: + id1 = id_pb2.MacaroonId.FromString(id[1:]) + except google.protobuf.message.DecodeError: + raise VerificationError( + 'no operations found in macaroon') + if len(id1.ops) == 0 or len(id1.ops[0].actions) == 0: + raise VerificationError( + 'no operations found in macaroon') + + ops = [] + for op in id1.ops: + for action in op.actions: + ops.append(Op(op.entity, action)) + return id1.storageId, ops + + if not base64_decoded and _is_lower_case_hex_char(first): + # It's an old-style id, probably with a hyphenated UUID. + # so trim that off. + last = id.rfind(b'-') + if last >= 0: + storage_id = id[0:last] + return storage_id, [LOGIN_OP] + + +def _is_lower_case_hex_char(b): + if ord('0') <= b <= ord('9'): + return True + if ord('a') <= b <= ord('f'): + return True + return False + + +def canonical_ops(ops): + ''' Returns the given operations array sorted with duplicates removed. + + @param ops checker.Ops + @return: checker.Ops + ''' + new_ops = sorted(set(ops), key=lambda x: (x.entity, x.action)) + return new_ops + + +def _macaroon_id_ops(ops): + '''Return operations suitable for serializing as part of a MacaroonId. + + It assumes that ops has been canonicalized and that there's at least + one operation. + ''' + id_ops = [] + for entity, entity_ops in itertools.groupby(ops, lambda x: x.entity): + actions = map(lambda x: x.action, entity_ops) + id_ops.append(id_pb2.Op(entity=entity, actions=actions)) + return id_ops diff --git a/macaroonbakery/bakery/_store.py b/macaroonbakery/bakery/_store.py new file mode 100644 index 0000000..ae5f7a7 --- /dev/null +++ b/macaroonbakery/bakery/_store.py @@ -0,0 +1,77 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +import abc +import os + + +class MemoryOpsStore: + ''' A multi-op store that stores the operations in memory. + ''' + def __init__(self): + self._store = {} + + def put_ops(self, key, time, ops): + ''' Put an ops only if not already there, otherwise it's a no op. + ''' + if self._store.get(key) is None: + self._store[key] = ops + + def get_ops(self, key): + ''' Returns ops from the key if found otherwise raises a KeyError. + ''' + ops = self._store.get(key) + if ops is None: + raise KeyError( + 'cannot get operations for {}'.format(key)) + return ops + + +class RootKeyStore(object): + ''' Defines a store for macaroon root keys. + ''' + __metaclass__ = abc.ABCMeta + + @abc.abstractmethod + def get(self, id): + ''' Returns the root key for the given id. + If the item is not there, it returns None. + @param id: bytes + @return: bytes + ''' + raise NotImplementedError('get method must be defined in ' + 'subclass') + + @abc.abstractmethod + def root_key(self): + ''' Returns the root key to be used for making a new macaroon, and an + id that can be used to look it up later with the get method. + Note that the root keys should remain available for as long as the + macaroons using them are valid. + Note that there is no need for it to return a new root key for every + call - keys may be reused, although some key cycling is over time is + advisable. + @return: bytes + ''' + + +class MemoryKeyStore(RootKeyStore): + ''' MemoryKeyStore returns an implementation of + Store that generates a single key and always + returns that from root_key. The same id ("0") is always + used. + ''' + def __init__(self, key=None): + ''' If the key is not specified a random key will be generated. + @param key: bytes + ''' + if key is None: + key = os.urandom(24) + self._key = key + + def get(self, id): + if id != b'0': + return None + return self._key + + def root_key(self): + return self._key, b'0' diff --git a/macaroonbakery/bakery/_third_party.py b/macaroonbakery/bakery/_third_party.py new file mode 100644 index 0000000..91eacaf --- /dev/null +++ b/macaroonbakery/bakery/_third_party.py @@ -0,0 +1,57 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +from collections import namedtuple + +import macaroonbakery.checkers as checkers + + +def legacy_namespace(): + ''' Standard namespace for pre-version3 macaroons. + ''' + ns = checkers.Namespace(None) + ns.register(checkers.STD_NAMESPACE, '') + return ns + + +class ThirdPartyCaveatInfo(namedtuple( + 'ThirdPartyCaveatInfo', + 'condition, first_party_public_key, third_party_key_pair, root_key, ' + 'caveat, version, id, namespace')): + '''ThirdPartyCaveatInfo holds the information decoded from + a third party caveat id. + + @param condition holds the third party condition to be discharged. + This is the only field that most third party dischargers will + need to consider. {str} + + @param first_party_public_key holds the public key of the party + that created the third party caveat. {PublicKey} + + @param third_party_key_pair holds the nacl private used to decrypt + the caveat - the key pair of the discharging service. {PrivateKey} + + @param root_key holds the secret root key encoded by the caveat. {bytes} + + @param caveat holds the full caveat id from + which all the other fields are derived. {bytes} + + @param version holds the version that was used to encode + the caveat id. {number} + + @param id holds the id of the third party caveat (the id that the + discharge macaroon should be given). This will differ from Caveat + when the caveat information is encoded separately. {bytes} + + @param namespace object that holds the namespace of the first party + that created the macaroon, as encoded by the party that added the + third party caveat. {checkers.Namespace} + ''' + + +class ThirdPartyInfo(namedtuple('ThirdPartyInfo', 'version, public_key')): + ''' ThirdPartyInfo holds information on a given third party + discharge service. + @param version The latest bakery protocol version supported + by the discharger {number} + @param public_key Public key of the third party {PublicKey} + ''' diff --git a/macaroonbakery/bakery/_versions.py b/macaroonbakery/bakery/_versions.py new file mode 100644 index 0000000..7446d31 --- /dev/null +++ b/macaroonbakery/bakery/_versions.py @@ -0,0 +1,9 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. + + +VERSION_0 = 0 +VERSION_1 = 1 +VERSION_2 = 2 +VERSION_3 = 3 +LATEST_VERSION = VERSION_3 diff --git a/macaroonbakery/checkers/__init__.py b/macaroonbakery/checkers/__init__.py new file mode 100644 index 0000000..b3ea466 --- /dev/null +++ b/macaroonbakery/checkers/__init__.py @@ -0,0 +1,82 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +from ._conditions import ( + STD_NAMESPACE, + COND_DECLARED, + COND_TIME_BEFORE, + COND_ERROR, + COND_ALLOW, + COND_DENY, + COND_NEED_DECLARED, +) +from ._caveat import ( + allow_caveat, + deny_caveat, + declared_caveat, + parse_caveat, + time_before_caveat, + Caveat, +) +from ._declared import ( + context_with_declared, + infer_declared, + infer_declared_from_conditions, + need_declared_caveat, +) +from ._operation import ( + context_with_operations, +) +from ._namespace import ( + Namespace, + deserialize_namespace +) +from ._time import ( + context_with_clock, + expiry_time, + macaroons_expiry_time, +) +from ._checkers import ( + Checker, + CheckerInfo, + RegisterError, +) +from ._auth_context import ( + AuthContext, + ContextKey, +) + +from ._utils import ( + condition_with_prefix, +) + +__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', + 'condition_with_prefix', + 'context_with_declared', + 'context_with_operations', + 'context_with_clock', + 'declared_caveat', + 'deny_caveat', + 'deserialize_namespace', + 'expiry_time', + 'infer_declared', + 'infer_declared_from_conditions', + 'macaroons_expiry_time', + '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..2ca5168 --- /dev/null +++ b/macaroonbakery/checkers/_auth_context.py @@ -0,0 +1,61 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +try: + from collections.abc import Mapping +except ImportError: + from collections import Mapping + + +class AuthContext(Mapping): + ''' Holds a set of keys and values relevant to authorization. + + It is passed as an argument to authorization checkers, so that the checkers + can access information about the context of the authorization request. + It is immutable - values can only be added by copying the whole thing. + ''' + def __init__(self, somedict=None): + if somedict is None: + somedict = {} + self._dict = dict(somedict) + self._hash = None + + def with_value(self, key, val): + ''' Return a copy of the AuthContext object with the given key and + value added. + ''' + new_dict = dict(self._dict) + new_dict[key] = val + return AuthContext(new_dict) + + def __getitem__(self, key): + return self._dict[key] + + def __len__(self): + return len(self._dict) + + def __iter__(self): + return iter(self._dict) + + def __hash__(self): + if self._hash is None: + self._hash = hash(frozenset(self._dict.items())) + return self._hash + + def __eq__(self, other): + return self._dict == other._dict + + +class ContextKey(object): + '''Provides a unique key suitable for use as a key into AuthContext.''' + + def __init__(self, name): + '''Creates a context key using the given name. The name is + only for informational purposes. + ''' + self._name = name + + def __str__(self): + return '%s#%#x' % (self._name, id(self)) + + def __repr__(self): + return 'context_key(%r, %#x)' % (self._name, id(self)) diff --git a/macaroonbakery/checkers/_caveat.py b/macaroonbakery/checkers/_caveat.py new file mode 100644 index 0000000..5732f43 --- /dev/null +++ b/macaroonbakery/checkers/_caveat.py @@ -0,0 +1,128 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +import collections + +import pyrfc3339 +from ._conditions import ( + COND_ALLOW, + COND_DECLARED, + COND_DENY, + COND_ERROR, + COND_TIME_BEFORE, + STD_NAMESPACE, +) + + +class Caveat(collections.namedtuple('Caveat', 'condition location namespace')): + '''Represents a condition that must be true for a check to complete + successfully. + + If location is provided, the caveat must be discharged by + a third party at the given location (a URL string). + + The namespace parameter holds the namespace URI string of the + condition - if it is provided, it will be converted to a namespace prefix + before adding to the macaroon. + ''' + __slots__ = () + + def __new__(cls, condition, location=None, namespace=None): + return super(Caveat, cls).__new__(cls, condition, location, namespace) + + +def declared_caveat(key, value): + '''Returns a "declared" caveat asserting that the given key is + set to the given value. + + If a macaroon has exactly one first party caveat asserting the value of a + particular key, then infer_declared will be able to infer the value, and + then the check will allow the declared value if it has the value + specified here. + + If the key is empty or contains a space, it will return an error caveat. + ''' + if key.find(' ') >= 0 or key == '': + return error_caveat('invalid caveat \'declared\' key "{}"'.format(key)) + return _first_party(COND_DECLARED, key + ' ' + value) + + +def error_caveat(f): + '''Returns a caveat that will never be satisfied, holding f as the text of + the caveat. + + This should only be used for highly unusual conditions that are never + expected to happen in practice, such as a malformed key that is + conventionally passed as a constant. It's not a panic but you should + only use it in cases where a panic might possibly be appropriate. + + This mechanism means that caveats can be created without error + checking and a later systematic check at a higher level (in the + bakery package) can produce an error instead. + ''' + return _first_party(COND_ERROR, f) + + +def allow_caveat(ops): + ''' Returns a caveat that will deny attempts to use the macaroon to perform + any operation other than those listed. Operations must not contain a space. + ''' + if ops is None or len(ops) == 0: + return error_caveat('no operations allowed') + return _operation_caveat(COND_ALLOW, ops) + + +def deny_caveat(ops): + '''Returns a caveat that will deny attempts to use the macaroon to perform + any of the listed operations. Operations must not contain a space. + ''' + return _operation_caveat(COND_DENY, ops) + + +def _operation_caveat(cond, ops): + ''' Helper for allow_caveat and deny_caveat. + + It checks that all operation names are valid before creating the caveat. + ''' + for op in ops: + if op.find(' ') != -1: + return error_caveat('invalid operation name "{}"'.format(op)) + return _first_party(cond, ' '.join(ops)) + + +def time_before_caveat(t): + '''Return a caveat that specifies that the time that it is checked at + should be before t. + :param t is a a UTC date in - use datetime.utcnow, not datetime.now + ''' + + return _first_party(COND_TIME_BEFORE, + pyrfc3339.generate(t, accept_naive=True, + microseconds=True)) + + +def parse_caveat(cav): + ''' Parses a caveat into an identifier, identifying the checker that should + be used, and the argument to the checker (the rest of the string). + + The identifier is taken from all the characters before the first + space character. + :return two string, identifier and arg + ''' + if cav == '': + raise ValueError('empty caveat') + try: + i = cav.index(' ') + except ValueError: + return cav, '' + if i == 0: + raise ValueError('caveat starts with space character') + return cav[0:i], cav[i + 1:] + + +def _first_party(name, arg): + condition = name + if arg != '': + condition += ' ' + arg + + return Caveat(condition=condition, + namespace=STD_NAMESPACE) diff --git a/macaroonbakery/checkers/_checkers.py b/macaroonbakery/checkers/_checkers.py new file mode 100644 index 0000000..11a41b9 --- /dev/null +++ b/macaroonbakery/checkers/_checkers.py @@ -0,0 +1,249 @@ +# 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 +from ._caveat import parse_caveat +from ._conditions import ( + COND_ALLOW, + COND_DECLARED, + COND_DENY, + COND_ERROR, + COND_TIME_BEFORE, + STD_NAMESPACE, +) +from ._declared import DECLARED_KEY +from ._namespace import Namespace +from ._operation import OP_KEY +from ._time import TIME_KEY +from ._utils import condition_with_prefix + + +class RegisterError(Exception): + '''Raised when a condition cannot be registered with a Checker.''' + pass + + +class FirstPartyCaveatChecker(object): + '''Used to check first party caveats for validity with respect to + information in the provided context. + + If the caveat kind was not recognised, the checker should return + ErrCaveatNotRecognized. + ''' + __metaclass__ = abc.ABCMeta + + @abc.abstractmethod + def check_first_party_caveat(self, ctx, caveat): + ''' Checks that the given caveat condition is valid with respect to + the given context information. + :param ctx: an Auth context + :param caveat a string + ''' + raise NotImplementedError('check_first_party_caveat method must be ' + 'defined in subclass') + + def namespace(self): + ''' Returns the namespace associated with the caveat checker. + ''' + raise NotImplementedError('namespace method must be ' + 'defined in subclass') + + +class Checker(FirstPartyCaveatChecker): + ''' Holds a set of checkers for first party caveats. + ''' + + def __init__(self, namespace=None, include_std_checkers=True): + if namespace is None: + namespace = Namespace() + self._namespace = namespace + self._checkers = {} + if include_std_checkers: + self.register_std() + + def check_first_party_caveat(self, ctx, cav): + ''' Checks the caveat against all registered caveat conditions. + :return: error message string if any or None + ''' + try: + cond, arg = parse_caveat(cav) + except ValueError as ex: + # If we can't parse it, perhaps it's in some other format, + # return a not-recognised error. + return 'cannot parse caveat "{}": {}'.format(cav, ex.args[0]) + checker = self._checkers.get(cond) + if checker is None: + return 'caveat "{}" not satisfied: caveat not recognized'.format( + cav) + err = checker.check(ctx, cond, arg) + if err is not None: + return 'caveat "{}" not satisfied: {}'.format(cav, err) + + def namespace(self): + ''' Returns the namespace associated with the Checker. + ''' + return self._namespace + + def info(self): + ''' Returns information on all the registered checkers. + + Sorted by namespace and then name + :returns a list of CheckerInfo + ''' + return sorted(self._checkers.values(), key=lambda x: (x.ns, x.name)) + + def register(self, cond, uri, check): + ''' Registers the given condition(string) in the given namespace + uri (string) to be checked with the given check function. + The check function checks a caveat by passing an auth context, a cond + parameter(string) that holds the caveat condition including any + namespace prefix and an arg parameter(string) that hold any additional + caveat argument text. It will return any error as string otherwise + None. + + It will raise a ValueError if the namespace is not registered or + if the condition has already been registered. + ''' + if check is None: + raise RegisterError( + 'no check function registered for namespace {} when ' + 'registering condition {}'.format(uri, cond)) + + prefix = self._namespace.resolve(uri) + if prefix is None: + raise RegisterError('no prefix registered for namespace {} when ' + 'registering condition {}'.format(uri, cond)) + + if prefix == '' and cond.find(':') >= 0: + raise RegisterError( + 'caveat condition {} in namespace {} contains a colon but its' + ' prefix is empty'.format(cond, uri)) + + full_cond = condition_with_prefix(prefix, cond) + info = self._checkers.get(full_cond) + if info is not None: + raise RegisterError( + 'checker for {} (namespace {}) already registered in ' + 'namespace {}'.format(full_cond, uri, info.ns)) + self._checkers[full_cond] = CheckerInfo( + check=check, + ns=uri, + name=cond, + prefix=prefix) + + def register_std(self): + ''' Registers all the standard checkers in the given checker. + + If not present already, the standard checkers schema (STD_NAMESPACE) is + added to the checker's namespace with an empty prefix. + ''' + self._namespace.register(STD_NAMESPACE, '') + for cond in _ALL_CHECKERS: + self.register(cond, STD_NAMESPACE, _ALL_CHECKERS[cond]) + + +class CheckerInfo(namedtuple('CheckInfo', 'prefix name ns check')): + '''CheckerInfo holds information on a registered checker. + ''' + __slots__ = () + + def __new__(cls, prefix, name, ns, check=None): + ''' + :param check holds the actual checker function which takes an auth + context and a condition and arg string as arguments. + :param prefix holds the prefix for the checker condition as string. + :param name holds the name of the checker condition as string. + :param ns holds the namespace URI for the checker's schema as + Namespace. + ''' + return super(CheckerInfo, cls).__new__(cls, prefix, name, ns, check) + + +def _check_time_before(ctx, cond, arg): + clock = ctx.get(TIME_KEY) + if clock is None: + now = datetime.utcnow() + else: + now = clock.utcnow() + + try: + # Note: pyrfc3339 returns a datetime with a timezone, which + # we need to remove before we can compare it with the naive + # datetime object returned by datetime.utcnow. + expiry = pyrfc3339.parse(arg, utc=True).replace(tzinfo=None) + if now >= expiry: + return 'macaroon has expired' + except ValueError: + return 'cannot parse "{}" as RFC 3339'.format(arg) + return None + + +def _check_declared(ctx, cond, arg): + parts = arg.split(' ', 1) + if len(parts) != 2: + return 'declared caveat has no value' + attrs = ctx.get(DECLARED_KEY, {}) + val = attrs.get(parts[0]) + if val is None: + return 'got {}=null, expected "{}"'.format(parts[0], parts[1]) + + if val != parts[1]: + return 'got {}="{}", expected "{}"'.format(parts[0], val, parts[1]) + return None + + +def _check_error(ctx, cond, arg): + return 'bad caveat' + + +def _check_allow(ctx, cond, arg): + return _check_operations(ctx, True, arg) + + +def _check_deny(ctx, cond, arg): + return _check_operations(ctx, False, arg) + + +def _check_operations(ctx, need_ops, arg): + ''' Checks an allow or a deny caveat. The need_ops parameter specifies + whether we require all the operations in the caveat to be declared in + the context. + ''' + ctx_ops = ctx.get(OP_KEY, []) + if len(ctx_ops) == 0: + if need_ops: + f = arg.split() + if len(f) == 0: + return 'no operations allowed' + return '{} not allowed'.format(f[0]) + return None + + fields = arg.split() + for op in ctx_ops: + err = _check_op(op, need_ops, fields) + if err is not None: + return err + return None + + +def _check_op(ctx_op, need_op, fields): + found = False + for op in fields: + if op == ctx_op: + found = True + break + if found != need_op: + return '{} not allowed'.format(ctx_op) + return None + + +_ALL_CHECKERS = { + COND_TIME_BEFORE: _check_time_before, + COND_DECLARED: _check_declared, + COND_ERROR: _check_error, + COND_ALLOW: _check_allow, + COND_DENY: _check_deny, +} diff --git a/macaroonbakery/checkers/_conditions.py b/macaroonbakery/checkers/_conditions.py new file mode 100644 index 0000000..74e863e --- /dev/null +++ b/macaroonbakery/checkers/_conditions.py @@ -0,0 +1,17 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. + +# StdNamespace holds the URI of the standard checkers schema. +STD_NAMESPACE = 'std' + +# Constants for all the standard caveat conditions. +# First and third party caveat conditions are both defined here, +# even though notionally they exist in separate name spaces. +COND_DECLARED = 'declared' +COND_TIME_BEFORE = 'time-before' +COND_ERROR = 'error' +COND_ALLOW = 'allow' +COND_DENY = 'deny' + + +COND_NEED_DECLARED = 'need-declared' diff --git a/macaroonbakery/checkers/_declared.py b/macaroonbakery/checkers/_declared.py new file mode 100644 index 0000000..ae4f95b --- /dev/null +++ b/macaroonbakery/checkers/_declared.py @@ -0,0 +1,84 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +from ._auth_context import ContextKey +from ._caveat import Caveat, error_caveat, parse_caveat +from ._conditions import ( + COND_DECLARED, + COND_NEED_DECLARED, + STD_NAMESPACE, +) +from ._namespace import Namespace + +DECLARED_KEY = ContextKey('declared-key') + + +def infer_declared(ms, namespace=None): + '''Retrieves any declared information from the given macaroons and returns + it as a key-value map. + Information is declared with a first party caveat as created by + declared_caveat. + + If there are two caveats that declare the same key with different values, + the information is omitted from the map. When the caveats are later + checked, this will cause the check to fail. + namespace is the Namespace used to retrieve the prefix associated to the + uri, if None it will use the STD_NAMESPACE only. + ''' + conditions = [] + for m in ms: + for cav in m.caveats: + if cav.location is None or cav.location == '': + conditions.append(cav.caveat_id_bytes.decode('utf-8')) + return infer_declared_from_conditions(conditions, namespace) + + +def infer_declared_from_conditions(conds, namespace=None): + ''' like infer_declared except that it is passed a set of first party + caveat conditions as a list of string rather than a set of macaroons. + ''' + conflicts = [] + # If we can't resolve that standard namespace, then we'll look for + # just bare "declared" caveats which will work OK for legacy + # macaroons with no namespace. + if namespace is None: + namespace = Namespace() + prefix = namespace.resolve(STD_NAMESPACE) + if prefix is None: + prefix = '' + declared_cond = prefix + COND_DECLARED + + info = {} + for cond in conds: + try: + name, rest = parse_caveat(cond) + except ValueError: + name, rest = '', '' + if name != declared_cond: + continue + parts = rest.split(' ', 1) + if len(parts) != 2: + continue + key, val = parts[0], parts[1] + old_val = info.get(key) + if old_val is not None and old_val != val: + conflicts.append(key) + continue + info[key] = val + for key in set(conflicts): + del info[key] + return info + + +def context_with_declared(ctx, declared): + ''' Returns a context with attached declared information, + as returned from infer_declared. + ''' + return ctx.with_value(DECLARED_KEY, declared) + + +def need_declared_caveat(cav, keys): + if cav.location == '': + return error_caveat('need-declared caveat is not third-party') + return Caveat(location=cav.location, + condition=(COND_NEED_DECLARED + ' ' + ','.join(keys) + + ' ' + cav.condition)) diff --git a/macaroonbakery/checkers/_namespace.py b/macaroonbakery/checkers/_namespace.py new file mode 100644 index 0000000..6c3b1e3 --- /dev/null +++ b/macaroonbakery/checkers/_namespace.py @@ -0,0 +1,165 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +import collections + +from ._caveat import error_caveat +from ._utils import condition_with_prefix + + +class Namespace: + '''Holds maps from schema URIs to prefixes. + + prefixes that are used to encode them in first party + caveats. Several different URIs may map to the same + prefix - this is usual when several different backwardly + compatible schema versions are registered. + ''' + + def __init__(self, uri_to_prefix=None): + self._uri_to_prefix = {} + if uri_to_prefix is not None: + for k in uri_to_prefix: + self.register(k, uri_to_prefix[k]) + + def __str__(self): + '''Returns the namespace representation as returned by serialize + :return: str + ''' + return self.serialize_text().decode('utf-8') + + def __eq__(self, other): + return self._uri_to_prefix == other._uri_to_prefix + + def serialize_text(self): + '''Returns a serialized form of the Namepace. + + All the elements in the namespace are sorted by + URI, joined to the associated prefix with a colon and + separated with spaces. + :return: bytes + ''' + if self._uri_to_prefix is None or len(self._uri_to_prefix) == 0: + return b'' + od = collections.OrderedDict(sorted(self._uri_to_prefix.items())) + data = [] + for uri in od: + data.append(uri + ':' + od[uri]) + return ' '.join(data).encode('utf-8') + + def register(self, uri, prefix): + '''Registers the given URI and associates it with the given prefix. + + If the URI has already been registered, this is a no-op. + + :param uri: string + :param prefix: string + ''' + if not is_valid_schema_uri(uri): + raise KeyError( + 'cannot register invalid URI {} (prefix {})'.format( + uri, prefix)) + if not is_valid_prefix(prefix): + raise ValueError( + 'cannot register invalid prefix %q for URI %q'.format( + prefix, uri)) + if self._uri_to_prefix.get(uri) is None: + self._uri_to_prefix[uri] = prefix + + def resolve(self, uri): + ''' Returns the prefix associated to the uri. + + returns None if not found. + :param uri: string + :return: string + ''' + return self._uri_to_prefix.get(uri) + + def resolve_caveat(self, cav): + ''' Resolves the given caveat(string) by using resolve to map from its + schema namespace to the appropriate prefix. + If there is no registered prefix for the namespace, it returns an error + caveat. + If cav.namespace is empty or cav.location is non-empty, it returns cav + unchanged. + + It does not mutate ns and may be called concurrently with other + non-mutating Namespace methods. + :return: Caveat object + ''' + # TODO: If a namespace isn't registered, try to resolve it by + # resolving it to the latest compatible version that is + # registered. + if cav.namespace == '' or cav.location != '': + return cav + + prefix = self.resolve(cav.namespace) + if prefix is None: + err_cav = error_caveat( + 'caveat {} in unregistered namespace {}'.format( + cav.condition, cav.namespace)) + if err_cav.namespace != cav.namespace: + prefix = self.resolve(err_cav.namespace) + if prefix is None: + prefix = '' + cav = err_cav + if prefix != '': + cav.condition = condition_with_prefix(prefix, cav.condition) + cav.namespace = '' + return cav + + +def is_valid_schema_uri(uri): + '''Reports if uri is suitable for use as a namespace schema URI. + + It must be non-empty and it must not contain white space. + + :param uri string + :return bool + ''' + if len(uri) <= 0: + return False + return uri.find(' ') == -1 + + +def is_valid_prefix(prefix): + '''Reports if prefix is valid. + + It must not contain white space or semi-colon. + :param prefix string + :return bool + ''' + return prefix.find(' ') == -1 and prefix.find(':') == -1 + + +def deserialize_namespace(data): + ''' Deserialize a Namespace object. + + :param data: bytes or str + :return: namespace + ''' + if isinstance(data, bytes): + data = data.decode('utf-8') + kvs = data.split() + uri_to_prefix = {} + for kv in kvs: + i = kv.rfind(':') + if i == -1: + raise ValueError('no colon in namespace ' + 'field {}'.format(repr(kv))) + uri, prefix = kv[0:i], kv[i + 1:] + if not is_valid_schema_uri(uri): + # Currently this can't happen because the only invalid URIs + # are those which contain a space + raise ValueError( + 'invalid URI {} in namespace ' + 'field {}'.format(repr(uri), repr(kv))) + if not is_valid_prefix(prefix): + raise ValueError( + 'invalid prefix {} in namespace field' + ' {}'.format(repr(prefix), repr(kv))) + if uri in uri_to_prefix: + raise ValueError( + 'duplicate URI {} in ' + 'namespace {}'.format(repr(uri), repr(data))) + uri_to_prefix[uri] = prefix + return Namespace(uri_to_prefix) diff --git a/macaroonbakery/checkers/_operation.py b/macaroonbakery/checkers/_operation.py new file mode 100644 index 0000000..56b267a --- /dev/null +++ b/macaroonbakery/checkers/_operation.py @@ -0,0 +1,17 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +from ._auth_context import ContextKey + +OP_KEY = ContextKey('op-key') + + +def context_with_operations(ctx, ops): + ''' Returns a context(AuthContext) which is associated with all the given + operations (list of string). It will be based on the auth context + passed in as ctx. + + An allow caveat will succeed only if one of the allowed operations is in + ops; a deny caveat will succeed only if none of the denied operations are + in ops. + ''' + return ctx.with_value(OP_KEY, ops) diff --git a/macaroonbakery/checkers/_time.py b/macaroonbakery/checkers/_time.py new file mode 100644 index 0000000..2ae1d89 --- /dev/null +++ b/macaroonbakery/checkers/_time.py @@ -0,0 +1,67 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. + +import pyrfc3339 +from ._auth_context import ContextKey +from ._caveat import parse_caveat +from ._conditions import COND_TIME_BEFORE, STD_NAMESPACE +from ._utils import condition_with_prefix + +TIME_KEY = ContextKey('time-key') + + +def context_with_clock(ctx, clock): + ''' Returns a copy of ctx with a key added that associates it with the + given clock implementation, which will be used by the time-before checker + to determine the current time. + The clock should have a utcnow method that returns the current time + as a datetime value in UTC. + ''' + if clock is None: + return ctx + return ctx.with_value(TIME_KEY, clock) + + +def macaroons_expiry_time(ns, ms): + ''' Returns the minimum time of any time-before caveats found in the given + macaroons or None if no such caveats were found. + :param ns: a Namespace, used to resolve caveats. + :param ms: a list of pymacaroons.Macaroon + :return: datetime.DateTime or None. + ''' + t = None + for m in ms: + et = expiry_time(ns, m.caveats) + if et is not None and (t is None or et < t): + t = et + return t + + +def expiry_time(ns, cavs): + ''' Returns the minimum time of any time-before caveats found + in the given list or None if no such caveats were found. + + The ns parameter is + :param ns: used to determine the standard namespace prefix - if + the standard namespace is not found, the empty prefix is assumed. + :param cavs: a list of pymacaroons.Caveat + :return: datetime.DateTime or None. + ''' + prefix = ns.resolve(STD_NAMESPACE) + time_before_cond = condition_with_prefix( + prefix, COND_TIME_BEFORE) + t = None + for cav in cavs: + if not cav.first_party(): + continue + cav = cav.caveat_id_bytes.decode('utf-8') + name, rest = parse_caveat(cav) + if name != time_before_cond: + continue + try: + et = pyrfc3339.parse(rest, utc=True).replace(tzinfo=None) + if t is None or et < t: + t = et + except ValueError: + continue + return t diff --git a/macaroonbakery/checkers/_utils.py b/macaroonbakery/checkers/_utils.py new file mode 100644 index 0000000..925e8c7 --- /dev/null +++ b/macaroonbakery/checkers/_utils.py @@ -0,0 +1,13 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. + + +def condition_with_prefix(prefix, condition): + '''Returns the given string prefixed by the given prefix. + + If the prefix is non-empty, a colon is used to separate them. + ''' + if prefix == '' or prefix is None: + return condition + + return prefix + ':' + condition diff --git a/macaroonbakery/httpbakery/__init__.py b/macaroonbakery/httpbakery/__init__.py new file mode 100644 index 0000000..07a805b --- /dev/null +++ b/macaroonbakery/httpbakery/__init__.py @@ -0,0 +1,55 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +from ._client import ( + BakeryException, + Client, + extract_macaroons, +) +from ._error import ( + BAKERY_PROTOCOL_HEADER, + DischargeError, + ERR_DISCHARGE_REQUIRED, + ERR_INTERACTION_REQUIRED, + Error, + ErrorInfo, + InteractionError, + InteractionMethodNotFound, + discharge_required_response, + request_version, +) +from ._keyring import ThirdPartyLocator +from ._interactor import ( + DischargeToken, + Interactor, + LegacyInteractor, + WEB_BROWSER_INTERACTION_KIND, +) +from ._browser import ( + WebBrowserInteractionInfo, + WebBrowserInteractor, +) +from ._discharge import discharge + +__all__ = [ + 'BAKERY_PROTOCOL_HEADER', + 'BakeryException', + 'Client', + 'DischargeError', + 'DischargeToken', + 'ERR_DISCHARGE_REQUIRED', + 'ERR_INTERACTION_REQUIRED', + 'Error', + 'ErrorInfo', + 'InteractionError', + 'InteractionMethodNotFound', + 'Interactor', + 'LegacyInteractor', + 'ThirdPartyLocator', + 'WEB_BROWSER_INTERACTION_KIND', + 'WebBrowserInteractionInfo', + 'WebBrowserInteractor', + 'discharge', + 'discharge_required_response', + 'extract_macaroons', + 'request_version', +] diff --git a/macaroonbakery/httpbakery/_browser.py b/macaroonbakery/httpbakery/_browser.py new file mode 100644 index 0000000..c8a5586 --- /dev/null +++ b/macaroonbakery/httpbakery/_browser.py @@ -0,0 +1,90 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +import base64 +from collections import namedtuple + +import requests +from ._error import InteractionError +from ._interactor import ( + WEB_BROWSER_INTERACTION_KIND, + DischargeToken, + Interactor, + LegacyInteractor, +) +from macaroonbakery._utils import visit_page_with_browser + +from six.moves.urllib.parse import urljoin + + +class WebBrowserInteractor(Interactor, LegacyInteractor): + ''' Handles web-browser-based interaction-required errors by opening a + web browser to allow the user to prove their credentials interactively. + ''' + def __init__(self, open=visit_page_with_browser): + '''Create a WebBrowserInteractor that uses the given function + to open a browser window. The open function is expected to take + a single argument of string type, the URL to open. + ''' + self._open_web_browser = open + + def kind(self): + return WEB_BROWSER_INTERACTION_KIND + + def legacy_interact(self, ctx, location, visit_url): + '''Implement LegacyInteractor.legacy_interact by opening the + web browser window''' + self._open_web_browser(visit_url) + + def interact(self, ctx, location, ir_err): + '''Implement Interactor.interact by opening the browser window + and waiting for the discharge token''' + p = ir_err.interaction_method(self.kind(), WebBrowserInteractionInfo) + if not location.endswith('/'): + location += '/' + visit_url = urljoin(location, p.visit_url) + wait_token_url = urljoin(location, p.wait_token_url) + self._open_web_browser(visit_url) + return self._wait_for_token(ctx, wait_token_url) + + def _wait_for_token(self, ctx, wait_token_url): + ''' Returns a token from a the wait token URL + @param wait_token_url URL to wait for (string) + :return DischargeToken + ''' + resp = requests.get(wait_token_url) + if resp.status_code != 200: + raise InteractionError('cannot get {}'.format(wait_token_url)) + json_resp = resp.json() + kind = json_resp.get('kind') + if kind is None: + raise InteractionError( + 'cannot get kind token from {}'.format(wait_token_url)) + token_val = json_resp.get('token') + if token_val is None: + token_val = json_resp.get('token64') + if token_val is None: + raise InteractionError( + 'cannot get token from {}'.format(wait_token_url)) + token_val = base64.b64decode(token_val) + return DischargeToken(kind=kind, value=token_val) + + +class WebBrowserInteractionInfo(namedtuple('WebBrowserInteractionInfo', + 'visit_url, wait_token_url')): + ''' holds the information expected in the browser-window interaction + entry in an interaction-required error. + + :param visit_url holds the URL to be visited in a web browser. + :param wait_token_url holds a URL that will block on GET until the browser + interaction has completed. + ''' + @classmethod + def from_dict(cls, info_dict): + '''Create a new instance of WebBrowserInteractionInfo, as expected + by the Error.interaction_method method. + @param info_dict The deserialized JSON object + @return a new WebBrowserInteractionInfo object. + ''' + return WebBrowserInteractionInfo( + visit_url=info_dict.get('VisitURL'), + wait_token_url=info_dict.get('WaitTokenURL')) diff --git a/macaroonbakery/httpbakery/_client.py b/macaroonbakery/httpbakery/_client.py new file mode 100644 index 0000000..326c33d --- /dev/null +++ b/macaroonbakery/httpbakery/_client.py @@ -0,0 +1,402 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +import base64 +import json +import logging + +import macaroonbakery.bakery as bakery +import macaroonbakery.checkers as checkers +import macaroonbakery._utils as utils +from ._browser import WebBrowserInteractor +from ._error import ( + BAKERY_PROTOCOL_HEADER, + ERR_DISCHARGE_REQUIRED, + ERR_INTERACTION_REQUIRED, + DischargeError, + Error, + InteractionError, + InteractionMethodNotFound, +) +from ._interactor import ( + WEB_BROWSER_INTERACTION_KIND, + LegacyInteractor, +) + +import requests +from six.moves.http_cookies import SimpleCookie +from six.moves.urllib.parse import urljoin + +TIME_OUT = 30 +MAX_DISCHARGE_RETRIES = 3 + +log = logging.getLogger('httpbakery') + + +class BakeryException(requests.RequestException): + '''Raised when some errors happen using the httpbakery + authorizer''' + + +class Client: + '''Client holds the context for making HTTP requests with macaroons. + To make a request, use the auth method to obtain + an HTTP authorizer suitable for passing as the auth parameter + to a requests method. Note that the same cookie jar + should be passed to requests as is used to initialize + the client. + For example: + import macaroonbakery.httpbakery + client = httpbakery.Client() + resp = requests.get('some protected url', + cookies=client.cookies, + auth=client.auth()) + @param interaction_methods A list of Interactor implementations. + @param key The private key of the client {bakery.PrivateKey} + @param cookies storage for the cookies {CookieJar}. It should be the + same as in the requests cookies. If not provided, one + will be created. + ''' + def __init__(self, interaction_methods=None, key=None, cookies=None): + if interaction_methods is None: + interaction_methods = [WebBrowserInteractor()] + if cookies is None: + cookies = requests.cookies.RequestsCookieJar() + self._interaction_methods = interaction_methods + self.key = key + self.cookies = cookies + + def auth(self): + '''Return an authorizer object suitable for passing + to requests methods that accept one. + If a request returns a discharge-required error, + the authorizer will acquire discharge macaroons + and retry the request. + ''' + return _BakeryAuth(self) + + def request(self, method, url, **kwargs): + '''Use the requests library to make a request. + Using this method is like doing: + + requests.request(method, url, auth=client.auth()) + ''' + # TODO should we raise an exception if auth or cookies are explicitly + # mentioned in kwargs? + kwargs['auth'] = self.auth() + kwargs['cookies'] = self.cookies + return requests.request(method=method, url=url, **kwargs) + + def handle_error(self, error, url): + '''Try to resolve the given error, which should be a response + to the given URL, by discharging any macaroon contained in + it. That is, if error.code is ERR_DISCHARGE_REQUIRED + then it will try to discharge err.info.macaroon. If the discharge + succeeds, the discharged macaroon will be saved to the client's cookie + jar, otherwise an exception will be raised. + ''' + if error.info is None or error.info.macaroon is None: + raise BakeryException('unable to read info in discharge error ' + 'response') + + discharges = bakery.discharge_all( + error.info.macaroon, + self.acquire_discharge, + self.key, + ) + macaroons = '[' + ','.join(map(utils.macaroon_to_json_string, + discharges)) + ']' + all_macaroons = base64.urlsafe_b64encode(utils.to_bytes(macaroons)) + + full_path = urljoin(url, error.info.macaroon_path) + if error.info.cookie_name_suffix is not None: + name = 'macaroon-' + error.info.cookie_name_suffix + else: + name = 'macaroon-auth' + expires = checkers.macaroons_expiry_time(checkers.Namespace(), discharges) + self.cookies.set_cookie(utils.cookie( + name=name, + value=all_macaroons.decode('ascii'), + url=full_path, + expires=expires, + )) + + def acquire_discharge(self, cav, payload): + ''' Request a discharge macaroon from the caveat location + as an HTTP URL. + @param cav Third party {pymacaroons.Caveat} to be discharged. + @param payload External caveat data {bytes}. + @return The acquired macaroon {macaroonbakery.Macaroon} + ''' + resp = self._acquire_discharge_with_token(cav, payload, None) + # TODO Fabrice what is the other http response possible ?? + if resp.status_code == 200: + return bakery.Macaroon.from_dict(resp.json().get('Macaroon')) + cause = Error.from_dict(resp.json()) + if cause.code != ERR_INTERACTION_REQUIRED: + raise DischargeError(cause.message) + if cause.info is None: + raise DischargeError( + 'interaction-required response with no info: {}'.format( + resp.json()) + ) + loc = cav.location + if not loc.endswith('/'): + loc = loc + '/' + token, m = self._interact(loc, cause, payload) + if m is not None: + # We've acquired the macaroon directly via legacy interaction. + return m + # Try to acquire the discharge again, but this time with + # the token acquired by the interaction method. + resp = self._acquire_discharge_with_token(cav, payload, token) + if resp.status_code == 200: + return bakery.Macaroon.from_dict(resp.json().get('Macaroon')) + else: + raise DischargeError( + 'discharge failed with code {}'.format(resp.status_code)) + + def _acquire_discharge_with_token(self, cav, payload, token): + req = {} + _add_json_binary_field(cav.caveat_id_bytes, req, 'id') + if token is not None: + _add_json_binary_field(token.value, req, 'token') + req['token-kind'] = token.kind + if payload is not None: + req['caveat64'] = base64.urlsafe_b64encode(payload).rstrip( + b'=').decode('utf-8') + loc = cav.location + if not loc.endswith('/'): + loc += '/' + target = urljoin(loc, 'discharge') + headers = { + BAKERY_PROTOCOL_HEADER: str(bakery.LATEST_VERSION) + } + return self.request('POST', target, data=req, headers=headers) + + def _interact(self, location, error_info, payload): + '''Gathers a macaroon by directing the user to interact with a + web page. The error_info argument holds the interaction-required + error response. + @return DischargeToken, bakery.Macaroon + ''' + if (self._interaction_methods is None or + len(self._interaction_methods) == 0): + raise InteractionError('interaction required but not possible') + # TODO(rogpeppe) make the robust against a wider range of error info. + if error_info.info.interaction_methods is None and \ + error_info.info.visit_url is not None: + # It's an old-style error; deal with it differently. + return None, self._legacy_interact(location, error_info) + for interactor in self._interaction_methods: + found = error_info.info.interaction_methods.get(interactor.kind()) + if found is None: + continue + try: + token = interactor.interact(self, location, error_info) + except InteractionMethodNotFound: + continue + if token is None: + raise InteractionError('interaction method returned an empty ' + 'token') + return token, None + + raise InteractionError('no supported interaction method') + + def _legacy_interact(self, location, error_info): + visit_url = urljoin(location, error_info.info.visit_url) + wait_url = urljoin(location, error_info.info.wait_url) + method_urls = { + "interactive": visit_url + } + if (len(self._interaction_methods) > 1 or + self._interaction_methods[0].kind() != + WEB_BROWSER_INTERACTION_KIND): + # We have several possible methods or we only support a non-window + # method, so we need to fetch the possible methods supported by + # the discharger. + method_urls = _legacy_get_interaction_methods(visit_url) + for interactor in self._interaction_methods: + kind = interactor.kind() + if kind == WEB_BROWSER_INTERACTION_KIND: + # This is the old name for browser-window interaction. + kind = "interactive" + + if not isinstance(interactor, LegacyInteractor): + # Legacy interaction mode isn't supported. + continue + + visit_url = method_urls.get(kind) + if visit_url is None: + continue + + visit_url = urljoin(location, visit_url) + interactor.legacy_interact(self, location, visit_url) + return _wait_for_macaroon(wait_url) + + raise InteractionError('no methods supported; supported [{}]; provided [{}]'.format( + ' '.join([x.kind() for x in self._interaction_methods]), + ' '.join(method_urls.keys()), + )) + + +class _BakeryAuth: + '''_BakeryAuth implements an authorizer as required + by the requests HTTP client. + ''' + def __init__(self, client): + ''' + @param interaction_methods A list of Interactor implementations. + @param key The private key of the client (macaroonbakery.PrivateKey) + @param cookies storage for the cookies {CookieJar}. It should be the + same as in the requests cookies. + ''' + self._client = client + + def __call__(self, req): + req.headers[BAKERY_PROTOCOL_HEADER] = str(bakery.LATEST_VERSION) + hook = _prepare_discharge_hook(req.copy(), self._client) + req.register_hook(event='response', hook=hook) + return req + + +def _prepare_discharge_hook(req, client): + ''' Return the hook function (called when the response is received.) + + This allows us to intercept the response and do any necessary + macaroon discharge before returning. + ''' + class Retry: + # Define a local class so that we can use its class variable as + # mutable state accessed by the closures below. + count = 0 + + def hook(response, *args, **kwargs): + ''' Requests hooks system, this is the hook for the response. + ''' + status_code = response.status_code + + if status_code != 407 and status_code != 401: + return response + if (status_code == 401 and response.headers.get('WWW-Authenticate') != + 'Macaroon'): + return response + + if response.headers.get('Content-Type') != 'application/json': + return response + errorJSON = response.json() + if errorJSON.get('Code') != ERR_DISCHARGE_REQUIRED: + return response + error = Error.from_dict(errorJSON) + Retry.count += 1 + if Retry.count >= MAX_DISCHARGE_RETRIES: + raise BakeryException('too many ({}) discharge requests'.format( + Retry.count) + ) + client.handle_error(error, req.url) + req.headers.pop('Cookie', None) + req.prepare_cookies(client.cookies) + req.headers[BAKERY_PROTOCOL_HEADER] = \ + str(bakery.LATEST_VERSION) + with requests.Session() as s: + settings = s.merge_environment_settings( + req.url, {}, None, None, None) + return s.send(req, **settings) + return hook + + +def extract_macaroons(headers_or_request): + ''' Returns an array of any macaroons found in the given slice of cookies. + If the argument implements a get_header method, that will be used + instead of the get method to retrieve headers. + @param headers_or_request: dict of headers or a + urllib.request.Request-like object. + @return: A list of list of mpy macaroons + ''' + def get_header(key, default=None): + try: + return headers_or_request.get_header(key, default) + except AttributeError: + return headers_or_request.get(key, default) + + mss = [] + + def add_macaroon(data): + try: + data = utils.b64decode(data) + data_as_objs = json.loads(data.decode('utf-8')) + except ValueError: + return + ms = [utils.macaroon_from_dict(x) for x in data_as_objs] + mss.append(ms) + + cookie_header = get_header('Cookie') + if cookie_header is not None: + cs = SimpleCookie() + # The cookie might be a unicode object, so convert it + # to ASCII. This may cause an exception under Python 2. + # TODO is that a problem? + cs.load(str(cookie_header)) + for c in cs: + if c.startswith('macaroon-'): + add_macaroon(cs[c].value) + # Python doesn't make it easy to have multiple values for a + # key, so split the header instead, which is necessary + # for HTTP1.1 compatibility anyway (see RFC 7230, section 3.2.2) + macaroon_header = get_header('Macaroons') + if macaroon_header is not None: + for h in macaroon_header.split(','): + add_macaroon(h) + return mss + + +def _add_json_binary_field(b, serialized, field): + '''' Set the given field to the given val (bytes) in the serialized + dictionary. + If the value isn't valid utf-8, we base64 encode it and use field+"64" + as the field name. + ''' + try: + val = b.decode('utf-8') + serialized[field] = val + except UnicodeDecodeError: + val = base64.b64encode(b).decode('utf-8') + serialized[field + '64'] = val + + +def _wait_for_macaroon(wait_url): + ''' Returns a macaroon from a legacy wait endpoint. + ''' + headers = { + BAKERY_PROTOCOL_HEADER: str(bakery.LATEST_VERSION) + } + resp = requests.get(url=wait_url, headers=headers) + if resp.status_code != 200: + raise InteractionError('cannot get {}'.format(wait_url)) + + return bakery.Macaroon.from_dict(resp.json().get('Macaroon')) + + +def _legacy_get_interaction_methods(u): + ''' Queries a URL as found in an ErrInteractionRequired VisitURL field to + find available interaction methods. + It does this by sending a GET request to the URL with the Accept + header set to "application/json" and parsing the resulting + response as a dict. + ''' + headers = { + BAKERY_PROTOCOL_HEADER: str(bakery.LATEST_VERSION), + 'Accept': 'application/json' + } + resp = requests.get(url=u, headers=headers) + method_urls = {} + if resp.status_code == 200: + json_resp = resp.json() + for m in json_resp: + method_urls[m] = urljoin(u, json_resp[m]) + + if method_urls.get('interactive') is None: + # There's no "interactive" method returned, but we know + # the server does actually support it, because all dischargers + # are required to, so fill it in with the original URL. + method_urls['interactive'] = u + return method_urls diff --git a/macaroonbakery/httpbakery/_discharge.py b/macaroonbakery/httpbakery/_discharge.py new file mode 100644 index 0000000..1873850 --- /dev/null +++ b/macaroonbakery/httpbakery/_discharge.py @@ -0,0 +1,37 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +import macaroonbakery.bakery as bakery +import macaroonbakery._utils as utils + + +def discharge(ctx, content, key, locator, checker): + '''Handles a discharge request as received by the /discharge + endpoint. + @param ctx The context passed to the checker {checkers.AuthContext} + @param content URL and form parameters {dict} + @param locator Locator used to add third party caveats returned by + the checker {macaroonbakery.ThirdPartyLocator} + @param checker {macaroonbakery.ThirdPartyCaveatChecker} Used to check third + party caveats. + @return The discharge macaroon {macaroonbakery.Macaroon} + ''' + id = content.get('id') + if id is not None: + id = id.encode('utf-8') + else: + id = content.get('id64') + if id is not None: + id = utils.b64decode(id) + + caveat = content.get('caveat64') + if caveat is not None: + caveat = utils.b64decode(caveat) + + return bakery.discharge( + ctx, + id=id, + caveat=caveat, + key=key, + checker=checker, + locator=locator, + ) diff --git a/macaroonbakery/httpbakery/_error.py b/macaroonbakery/httpbakery/_error.py new file mode 100644 index 0000000..0ef7e7b --- /dev/null +++ b/macaroonbakery/httpbakery/_error.py @@ -0,0 +1,207 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +import json +from collections import namedtuple + +import macaroonbakery.bakery as bakery + +ERR_INTERACTION_REQUIRED = 'interaction required' +ERR_DISCHARGE_REQUIRED = 'macaroon discharge required' + + +class InteractionMethodNotFound(Exception): + '''This is thrown by client-side interaction methods when + they find that a given interaction isn't supported by the + client for a location''' + pass + + +class DischargeError(Exception): + '''This is thrown by Client when a third party has refused a discharge''' + def __init__(self, msg): + super(DischargeError, self).__init__( + 'third party refused dischargex: {}'.format(msg)) + + +class InteractionError(Exception): + '''This is thrown by Client when it fails to deal with an + interaction-required error + ''' + def __init__(self, msg): + super(InteractionError, self).__init__( + 'cannot start interactive session: {}'.format(msg)) + + +def discharge_required_response(macaroon, path, cookie_suffix_name, + message=None): + ''' Get response content and headers from a discharge macaroons error. + + @param macaroon may hold a macaroon that, when discharged, may + allow access to a service. + @param path holds the URL path to be associated with the macaroon. + The macaroon is potentially valid for all URLs under the given path. + @param cookie_suffix_name holds the desired cookie name suffix to be + associated with the macaroon. The actual name used will be + ("macaroon-" + CookieName). Clients may ignore this field - + older clients will always use ("macaroon-" + macaroon.signature() in hex) + @return content(bytes) and the headers to set on the response(dict). + ''' + if message is None: + message = 'discharge required' + content = json.dumps( + { + 'Code': 'macaroon discharge required', + 'Message': message, + 'Info': { + 'Macaroon': macaroon.to_dict(), + 'MacaroonPath': path, + 'CookieNameSuffix': cookie_suffix_name + }, + } + ).encode('utf-8') + return content, { + 'WWW-Authenticate': 'Macaroon', + 'Content-Type': 'application/json' + } + +# BAKERY_PROTOCOL_HEADER is the header that HTTP clients should set +# to determine the bakery protocol version. If it is 0 or missing, +# a discharge-required error response will be returned with HTTP status 407; +# if it is greater than 0, the response will have status 401 with the +# WWW-Authenticate header set to "Macaroon". +BAKERY_PROTOCOL_HEADER = 'Bakery-Protocol-Version' + + +def request_version(req_headers): + ''' Determines the bakery protocol version from a client request. + If the protocol cannot be determined, or is invalid, the original version + of the protocol is used. If a later version is found, the latest known + version is used, which is OK because versions are backwardly compatible. + + @param req_headers: the request headers as a dict. + @return: bakery protocol version (for example macaroonbakery.VERSION_1) + ''' + vs = req_headers.get(BAKERY_PROTOCOL_HEADER) + if vs is None: + # No header - use backward compatibility mode. + return bakery.VERSION_1 + try: + x = int(vs) + except ValueError: + # Badly formed header - use backward compatibility mode. + return bakery.VERSION_1 + if x > bakery.LATEST_VERSION: + # Later version than we know about - use the + # latest version that we can. + return bakery.LATEST_VERSION + return x + + +class Error(namedtuple('Error', 'code, message, version, info')): + '''This class defines an error value as returned from + an httpbakery API. + ''' + @classmethod + def from_dict(cls, serialized): + '''Create an error from a JSON-deserialized object + @param serialized the object holding the serialized error {dict} + ''' + # Some servers return lower case field names for message and code. + # The Go client is tolerant of this, so be similarly tolerant here. + def field(name): + return serialized.get(name) or serialized.get(name.lower()) + return Error( + code=field('Code'), + message=field('Message'), + info=ErrorInfo.from_dict(field('Info')), + version=bakery.LATEST_VERSION, + ) + + def interaction_method(self, kind, x): + ''' Checks whether the error is an InteractionRequired error + that implements the method with the given name, and JSON-unmarshals the + method-specific data into x by calling its from_dict method + with the deserialized JSON object. + @param kind The interaction method kind (string). + @param x A class with a class method from_dict that returns a new + instance of the interaction info for the given kind. + @return The result of x.from_dict. + ''' + if self.info is None or self.code != ERR_INTERACTION_REQUIRED: + raise InteractionError( + 'not an interaction-required error (code {})'.format( + self.code) + ) + entry = self.info.interaction_methods.get(kind) + if entry is None: + raise InteractionMethodNotFound( + 'interaction method {} not found'.format(kind) + ) + return x.from_dict(entry) + + +class ErrorInfo( + namedtuple('ErrorInfo', 'macaroon, macaroon_path, cookie_name_suffix, ' + 'interaction_methods, visit_url, wait_url')): + ''' Holds additional information provided + by an error. + + @param macaroon may hold a macaroon that, when + discharged, may allow access to a service. + This field is associated with the ERR_DISCHARGE_REQUIRED + error code. + + @param macaroon_path holds the URL path to be associated + with the macaroon. The macaroon is potentially + valid for all URLs under the given path. + If it is empty, the macaroon will be associated with + the original URL from which the error was returned. + + @param cookie_name_suffix holds the desired cookie name suffix to be + associated with the macaroon. The actual name used will be + ("macaroon-" + cookie_name_suffix). Clients may ignore this field - + older clients will always use ("macaroon-" + + macaroon.signature() in hex). + + @param visit_url holds a URL that the client should visit + in a web browser to authenticate themselves. + + @param wait_url holds a URL that the client should visit + to acquire the discharge macaroon. A GET on + this URL will block until the client has authenticated, + and then it will return the discharge macaroon. + ''' + + __slots__ = () + + @classmethod + def from_dict(cls, serialized): + '''Create a new ErrorInfo object from a JSON deserialized + dictionary + @param serialized The JSON object {dict} + @return ErrorInfo object + ''' + if serialized is None: + return None + macaroon = serialized.get('Macaroon') + if macaroon is not None: + macaroon = bakery.Macaroon.from_dict(macaroon) + path = serialized.get('MacaroonPath') + cookie_name_suffix = serialized.get('CookieNameSuffix') + visit_url = serialized.get('VisitURL') + wait_url = serialized.get('WaitURL') + interaction_methods = serialized.get('InteractionMethods') + return ErrorInfo(macaroon=macaroon, macaroon_path=path, + cookie_name_suffix=cookie_name_suffix, + visit_url=visit_url, wait_url=wait_url, + interaction_methods=interaction_methods) + + def __new__(cls, macaroon=None, macaroon_path=None, + cookie_name_suffix=None, interaction_methods=None, + visit_url=None, wait_url=None): + '''Override the __new__ method so that we can + have optional arguments, which namedtuple doesn't + allow''' + return super(ErrorInfo, cls).__new__( + cls, macaroon, macaroon_path, cookie_name_suffix, + interaction_methods, visit_url, wait_url) diff --git a/macaroonbakery/httpbakery/_interactor.py b/macaroonbakery/httpbakery/_interactor.py new file mode 100644 index 0000000..7fba4ef --- /dev/null +++ b/macaroonbakery/httpbakery/_interactor.py @@ -0,0 +1,70 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +import abc +from collections import namedtuple + +WEB_BROWSER_INTERACTION_KIND = 'browser-window' + + +class Interactor(object): + ''' Represents a way of persuading a discharger that it should grant a + discharge macaroon. + ''' + __metaclass__ = abc.ABCMeta + + @abc.abstractmethod + def kind(self): + '''Returns the interaction method name. This corresponds to the key in + the Error.interaction_methods type. + @return {str} + ''' + raise NotImplementedError('kind method must be defined in subclass') + + def interact(self, client, location, interaction_required_err): + ''' Performs the interaction, and returns a token that can be + used to acquire the discharge macaroon. The location provides + the third party caveat location to make it possible to use + relative URLs. The client holds the client being used to do the current + request. + + If the given interaction isn't supported by the client for + the given location, it may raise an InteractionMethodNotFound + which will cause the interactor to be ignored that time. + @param client The client being used for the current request {Client} + @param location Third party caveat location {str} + @param interaction_required_err The error causing the interaction to + take place {Error} + @return {DischargeToken} The discharge token. + ''' + raise NotImplementedError('interact method must be defined in subclass') + + +class LegacyInteractor(object): + ''' May optionally be implemented by Interactor implementations that + implement the legacy interaction-required error protocols. + ''' + __metaclass__ = abc.ABCMeta + + @abc.abstractmethod + def legacy_interact(self, client, location, visit_url): + ''' Implements the "visit" half of a legacy discharge + interaction. The "wait" half will be implemented by httpbakery. + The location is the location specified by the third party + caveat. The client holds the client being used to do the current + request. + @param client The client being used for the current request {Client} + @param location Third party caveat location {str} + @param visit_url The visit_url field from the error {str} + @return None + ''' + raise NotImplementedError('legacy_interact method must be defined in subclass') + + +class DischargeToken(namedtuple('DischargeToken', 'kind, value')): + ''' Holds a token that is intended to persuade a discharger to discharge + a third party caveat. + @param kind holds the kind of the token. By convention this + matches the name of the interaction method used to + obtain the token, but that's not required {str} + @param value holds the token data. {bytes} + ''' diff --git a/macaroonbakery/httpbakery/_keyring.py b/macaroonbakery/httpbakery/_keyring.py new file mode 100644 index 0000000..8d9ab43 --- /dev/null +++ b/macaroonbakery/httpbakery/_keyring.py @@ -0,0 +1,60 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +import macaroonbakery.bakery as bakery +import requests +from ._error import BAKERY_PROTOCOL_HEADER + +from six.moves.urllib.parse import urlparse + + +class ThirdPartyLocator(bakery.ThirdPartyLocator): + ''' Implements macaroonbakery.ThirdPartyLocator by first looking in the + backing cache and, if that fails, making an HTTP request to find the + information associated with the given discharge location. + ''' + + def __init__(self, allow_insecure=False): + ''' + @param url: the url to retrieve public_key + @param allow_insecure: By default it refuses to use insecure URLs. + ''' + self._allow_insecure = allow_insecure + self._cache = {} + + def third_party_info(self, loc): + u = urlparse(loc) + if u.scheme != 'https' and not self._allow_insecure: + raise bakery.ThirdPartyInfoNotFound( + 'untrusted discharge URL {}'.format(loc)) + loc = loc.rstrip('/') + info = self._cache.get(loc) + if info is not None: + return info + url_endpoint = '/discharge/info' + headers = { + BAKERY_PROTOCOL_HEADER: str(bakery.LATEST_VERSION) + } + resp = requests.get(url=loc + url_endpoint, headers=headers) + status_code = resp.status_code + if status_code == 404: + url_endpoint = '/publickey' + resp = requests.get(url=loc + url_endpoint, headers=headers) + status_code = resp.status_code + if status_code != 200: + raise bakery.ThirdPartyInfoNotFound( + 'unable to get info from {}'.format(url_endpoint)) + json_resp = resp.json() + if json_resp is None: + raise bakery.ThirdPartyInfoNotFound( + 'no response from /discharge/info') + pk = json_resp.get('PublicKey') + if pk is None: + raise bakery.ThirdPartyInfoNotFound( + 'no public key found in /discharge/info') + idm_pk = bakery.PublicKey.deserialize(pk) + version = json_resp.get('Version', bakery.VERSION_1) + self._cache[loc] = bakery.ThirdPartyInfo( + version=version, + public_key=idm_pk + ) + return self._cache.get(loc) diff --git a/macaroonbakery/httpbakery/agent/__init__.py b/macaroonbakery/httpbakery/agent/__init__.py new file mode 100644 index 0000000..c0a7523 --- /dev/null +++ b/macaroonbakery/httpbakery/agent/__init__.py @@ -0,0 +1,19 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. + +from ._agent import ( + load_auth_info, + read_auth_info, + Agent, + AgentInteractor, + AgentFileFormatError, + AuthInfo, +) +__all__ = [ + 'Agent', + 'AgentFileFormatError', + 'AgentInteractor', + 'AuthInfo', + 'load_auth_info', + 'read_auth_info', +] diff --git a/macaroonbakery/httpbakery/agent/_agent.py b/macaroonbakery/httpbakery/agent/_agent.py new file mode 100644 index 0000000..618097c --- /dev/null +++ b/macaroonbakery/httpbakery/agent/_agent.py @@ -0,0 +1,185 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +import copy +import json +import logging +from collections import namedtuple + +import macaroonbakery.bakery as bakery +import macaroonbakery.httpbakery as httpbakery +import macaroonbakery._utils as utils +import requests.cookies + +from six.moves.urllib.parse import urljoin + +log = logging.getLogger(__name__) + + +class AgentFileFormatError(Exception): + ''' AgentFileFormatError is the exception raised when an agent file has a + bad structure. + ''' + pass + + +def load_auth_info(filename): + '''Loads agent authentication information from the specified file. + The returned information is suitable for passing as an argument + to the AgentInteractor constructor. + @param filename The name of the file to open (str) + @return AuthInfo The authentication information + @raises AgentFileFormatError when the file format is bad. + ''' + with open(filename) as f: + return read_auth_info(f.read()) + + +def read_auth_info(agent_file_content): + '''Loads agent authentication information from the + specified content string, as read from an agents file. + The returned information is suitable for passing as an argument + to the AgentInteractor constructor. + @param agent_file_content The agent file content (str) + @return AuthInfo The authentication information + @raises AgentFileFormatError when the file format is bad. + ''' + try: + data = json.loads(agent_file_content) + return AuthInfo( + key=bakery.PrivateKey.deserialize(data['key']['private']), + agents=list( + Agent(url=a['url'], username=a['username']) + for a in data.get('agents', []) + ), + ) + except ( + KeyError, + ValueError, + TypeError, + ) as e: + raise AgentFileFormatError('invalid agent file', e) + + +class InteractionInfo(object): + '''Holds the information expected in the agent interaction entry in an + interaction-required error. + ''' + def __init__(self, login_url): + self._login_url = login_url + + @property + def login_url(self): + ''' Return the URL from which to acquire a macaroon that can be used + to complete the agent login. To acquire the macaroon, make a POST + request to the URL with user and public-key parameters. + :return string + ''' + return self._login_url + + @classmethod + def from_dict(cls, json_dict): + '''Return an InteractionInfo obtained from the given dictionary as + deserialized from JSON. + @param json_dict The deserialized JSON object. + ''' + return InteractionInfo(json_dict.get('login-url')) + + +class AgentInteractor(httpbakery.Interactor, httpbakery.LegacyInteractor): + ''' Interactor that performs interaction using the agent login protocol. + ''' + def __init__(self, auth_info): + self._auth_info = auth_info + + def kind(self): + '''Implement Interactor.kind by returning the agent kind''' + return 'agent' + + def interact(self, client, location, interaction_required_err): + '''Implement Interactor.interact by obtaining obtaining + a macaroon from the discharger, discharging it with the + local private key using the discharged macaroon as + a discharge token''' + p = interaction_required_err.interaction_method('agent', + InteractionInfo) + if p.login_url is None or p.login_url == '': + raise httpbakery.InteractionError( + 'no login-url field found in agent interaction method') + agent = self._find_agent(location) + if not location.endswith('/'): + location += '/' + login_url = urljoin(location, p.login_url) + resp = requests.get( + login_url, params={ + 'username': agent.username, + 'public-key': str(self._auth_info.key.public_key)}, + auth=client.auth()) + if resp.status_code != 200: + raise httpbakery.InteractionError( + 'cannot acquire agent macaroon: {} {}'.format( + resp.status_code, resp.text) + ) + m = resp.json().get('macaroon') + if m is None: + raise httpbakery.InteractionError('no macaroon in response') + m = bakery.Macaroon.from_dict(m) + ms = bakery.discharge_all(m, None, self._auth_info.key) + b = bytearray() + for m in ms: + b.extend(utils.b64decode(m.serialize())) + return httpbakery.DischargeToken(kind='agent', value=bytes(b)) + + def _find_agent(self, location): + ''' Finds an appropriate agent entry for the given location. + :return Agent + ''' + for a in self._auth_info.agents: + # Don't worry about trailing slashes + if a.url.rstrip('/') == location.rstrip('/'): + return a + raise httpbakery.InteractionMethodNotFound( + 'cannot find username for discharge location {}'.format(location)) + + def legacy_interact(self, client, location, visit_url): + '''Implement LegacyInteractor.legacy_interact by obtaining + the discharge macaroon using the client's private key + ''' + agent = self._find_agent(location) + # Shallow-copy the client so that we don't unexpectedly side-effect + # it by changing the key. Another possibility might be to + # set up agent authentication differently, in such a way that + # we're sure that client.key is the same as self._auth_info.key. + client = copy.copy(client) + client.key = self._auth_info.key + resp = client.request( + method='POST', + url=visit_url, + json={ + 'username': agent.username, + 'public_key': str(self._auth_info.key.public_key), + }, + ) + if resp.status_code != 200: + raise httpbakery.InteractionError( + 'cannot acquire agent macaroon from {}: {} (response body: {!r})'.format(visit_url, resp.status_code, resp.text)) + if not resp.json().get('agent_login', False): + raise httpbakery.InteractionError('agent login failed') + + +class Agent(namedtuple('Agent', 'url, username')): + ''' Represents an agent that can be used for agent authentication. + @param url(string) holds the URL of the discharger that knows about + the agent. + @param username holds the username agent (string). + ''' + + +class AuthInfo(namedtuple('AuthInfo', 'key, agents')): + ''' Holds the agent information required to set up agent authentication + information. + + It holds the agent's private key and information about the username + associated with each known agent-authentication server. + @param key the agent's private key (bakery.PrivateKey). + @param agents information about the known agents (list of Agent). + ''' diff --git a/macaroonbakery/tests/__init__.py b/macaroonbakery/tests/__init__.py new file mode 100644 index 0000000..46812ee --- /dev/null +++ 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..aacdaf3 --- /dev/null +++ b/macaroonbakery/tests/common.py @@ -0,0 +1,119 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +from datetime import datetime, timedelta + +import macaroonbakery.bakery as bakery +import macaroonbakery.checkers as checkers + + +class _StoppedClock(object): + def __init__(self, t): + self.t = t + + def utcnow(self): + return self.t + + +epoch = 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(bakery.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(bakery.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 bakery.ThirdPartyCaveatCheckFailed( + '{} doesn\'t match {}'.format(repr(condition), repr(self.str))) + return [] + + +class ThirdPartyCheckerWithCaveats(bakery.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(bakery.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 = bakery.generate_key() + if locator is not None: + locator.add_info(location, + bakery.ThirdPartyInfo( + public_key=key.public_key, + version=bakery.LATEST_VERSION)) + return bakery.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 new file mode 100644 index 0000000..d670485 --- /dev/null +++ b/macaroonbakery/tests/test_agent.py @@ -0,0 +1,419 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +import json +import logging +import os +import tempfile +from datetime import datetime, timedelta +from unittest import TestCase + +import macaroonbakery.bakery as bakery +import macaroonbakery.checkers as checkers +import macaroonbakery.httpbakery as httpbakery +import macaroonbakery.httpbakery.agent as agent +import requests.cookies + +from httmock import HTTMock, response, urlmatch +from six.moves.urllib.parse import parse_qs, urlparse + +log = logging.getLogger(__name__) + +PRIVATE_KEY = 'CqoSgj06Zcgb4/S6RT4DpTjLAfKoznEY3JsShSjKJEU=' +PUBLIC_KEY = 'YAhRSsth3a36mRYqQGQaLiS4QJax0p356nd+B8x7UQE=' + + +class TestAgents(TestCase): + def setUp(self): + fd, filename = tempfile.mkstemp() + with os.fdopen(fd, 'w') as f: + f.write(agent_file) + self.agent_filename = filename + fd, filename = tempfile.mkstemp() + with os.fdopen(fd, 'w') as f: + f.write(bad_key_agent_file) + self.bad_key_agent_filename = filename + fd, filename = tempfile.mkstemp() + with os.fdopen(fd, 'w') as f: + f.write(no_username_agent_file) + self.no_username_agent_filename = filename + + def tearDown(self): + os.remove(self.agent_filename) + os.remove(self.bad_key_agent_filename) + os.remove(self.no_username_agent_filename) + + def test_load_auth_info(self): + auth_info = agent.load_auth_info(self.agent_filename) + self.assertEqual(str(auth_info.key), PRIVATE_KEY) + self.assertEqual(str(auth_info.key.public_key), PUBLIC_KEY) + self.assertEqual(auth_info.agents, [ + agent.Agent(url='https://1.example.com/', username='user-1'), + agent.Agent(url='https://2.example.com/discharger', username='user-2'), + agent.Agent(url='http://0.3.2.1', username='test-user'), + ]) + + def test_invalid_agent_json(self): + with self.assertRaises(agent.AgentFileFormatError): + agent.read_auth_info('}') + + def test_invalid_read_auth_info_arg(self): + with self.assertRaises(agent.AgentFileFormatError): + agent.read_auth_info(0) + + def test_load_auth_info_with_bad_key(self): + with self.assertRaises(agent.AgentFileFormatError): + agent.load_auth_info(self.bad_key_agent_filename) + + def test_load_auth_info_with_no_username(self): + with self.assertRaises(agent.AgentFileFormatError): + agent.load_auth_info(self.no_username_agent_filename) + + def test_agent_login(self): + discharge_key = bakery.generate_key() + + class _DischargerLocator(bakery.ThirdPartyLocator): + def third_party_info(self, loc): + if loc == 'http://0.3.2.1': + return bakery.ThirdPartyInfo( + public_key=discharge_key.public_key, + version=bakery.LATEST_VERSION, + ) + d = _DischargerLocator() + server_key = bakery.generate_key() + server_bakery = bakery.Bakery(key=server_key, locator=d) + + @urlmatch(path='.*/here') + def server_get(url, request): + ctx = checkers.AuthContext() + test_ops = [bakery.Op(entity='test-op', action='read')] + auth_checker = server_bakery.checker.auth( + httpbakery.extract_macaroons(request.headers)) + try: + auth_checker.allow(ctx, test_ops) + resp = response(status_code=200, + content='done') + except bakery.PermissionDenied: + caveats = [ + checkers.Caveat(location='http://0.3.2.1', + condition='is-ok') + ] + m = server_bakery.oven.macaroon( + version=bakery.LATEST_VERSION, + expiry=datetime.utcnow() + timedelta(days=1), + caveats=caveats, ops=test_ops) + content, headers = httpbakery.discharge_required_response( + m, '/', + 'test', + 'message') + resp = response(status_code=401, + content=content, + headers=headers) + return request.hooks['response'][0](resp) + + @urlmatch(path='.*/discharge') + def discharge(url, request): + qs = parse_qs(request.body) + if qs.get('token64') is None: + return response( + status_code=401, + content={ + 'Code': httpbakery.ERR_INTERACTION_REQUIRED, + 'Message': 'interaction required', + 'Info': { + 'InteractionMethods': { + 'agent': {'login-url': '/login'}, + }, + }, + }, + headers={'Content-Type': 'application/json'}) + else: + qs = parse_qs(request.body) + content = {q: qs[q][0] for q in qs} + m = httpbakery.discharge(checkers.AuthContext(), content, + discharge_key, None, alwaysOK3rd) + return { + 'status_code': 200, + 'content': { + 'Macaroon': m.to_dict() + } + } + + auth_info = agent.load_auth_info(self.agent_filename) + + @urlmatch(path='.*/login') + def login(url, request): + qs = parse_qs(urlparse(request.url).query) + self.assertEqual(request.method, 'GET') + self.assertEqual( + qs, {'username': ['test-user'], 'public-key': [PUBLIC_KEY]}) + b = bakery.Bakery(key=discharge_key) + m = b.oven.macaroon( + version=bakery.LATEST_VERSION, + expiry=datetime.utcnow() + timedelta(days=1), + caveats=[bakery.local_third_party_caveat( + PUBLIC_KEY, + version=httpbakery.request_version(request.headers))], + ops=[bakery.Op(entity='agent', action='login')]) + return { + 'status_code': 200, + 'content': { + 'macaroon': m.to_dict() + } + } + + with HTTMock(server_get), \ + HTTMock(discharge), \ + HTTMock(login): + client = httpbakery.Client(interaction_methods=[ + agent.AgentInteractor(auth_info), + ]) + resp = requests.get( + 'http://0.1.2.3/here', + cookies=client.cookies, + auth=client.auth()) + self.assertEqual(resp.content, b'done') + + def test_agent_legacy(self): + discharge_key = bakery.generate_key() + + class _DischargerLocator(bakery.ThirdPartyLocator): + def third_party_info(self, loc): + if loc == 'http://0.3.2.1': + return bakery.ThirdPartyInfo( + public_key=discharge_key.public_key, + version=bakery.LATEST_VERSION, + ) + d = _DischargerLocator() + server_key = bakery.generate_key() + server_bakery = bakery.Bakery(key=server_key, locator=d) + + @urlmatch(path='.*/here') + def server_get(url, request): + ctx = checkers.AuthContext() + test_ops = [bakery.Op(entity='test-op', action='read')] + auth_checker = server_bakery.checker.auth( + httpbakery.extract_macaroons(request.headers)) + try: + auth_checker.allow(ctx, test_ops) + resp = response(status_code=200, + content='done') + except bakery.PermissionDenied: + caveats = [ + checkers.Caveat(location='http://0.3.2.1', + condition='is-ok') + ] + m = server_bakery.oven.macaroon( + version=bakery.LATEST_VERSION, + expiry=datetime.utcnow() + timedelta(days=1), + caveats=caveats, ops=test_ops) + content, headers = httpbakery.discharge_required_response( + m, '/', + 'test', + 'message') + resp = response( + status_code=401, + content=content, + headers=headers, + ) + return request.hooks['response'][0](resp) + + class InfoStorage: + info = None + + @urlmatch(path='.*/discharge') + def discharge(url, request): + qs = parse_qs(request.body) + if qs.get('caveat64') is not None: + content = {q: qs[q][0] for q in qs} + + class InteractionRequiredError(Exception): + def __init__(self, error): + self.error = error + + class CheckerInError(bakery.ThirdPartyCaveatChecker): + def check_third_party_caveat(self, ctx, info): + InfoStorage.info = info + raise InteractionRequiredError( + httpbakery.Error( + code=httpbakery.ERR_INTERACTION_REQUIRED, + version=httpbakery.request_version( + request.headers), + message='interaction required', + info=httpbakery.ErrorInfo( + wait_url='http://0.3.2.1/wait?' + 'dischargeid=1', + visit_url='http://0.3.2.1/visit?' + 'dischargeid=1' + ), + ), + ) + try: + httpbakery.discharge( + checkers.AuthContext(), content, + discharge_key, None, CheckerInError()) + except InteractionRequiredError as exc: + return response( + status_code=401, + content={ + 'Code': exc.error.code, + 'Message': exc.error.message, + 'Info': { + 'WaitURL': exc.error.info.wait_url, + 'VisitURL': exc.error.info.visit_url, + }, + }, + headers={'Content-Type': 'application/json'}) + + key = bakery.generate_key() + + @urlmatch(path='.*/visit') + def visit(url, request): + if request.headers.get('Accept') == 'application/json': + return { + 'status_code': 200, + 'content': { + 'agent': '/agent-visit', + } + } + raise Exception('unexpected call to visit without Accept header') + + @urlmatch(path='.*/agent-visit') + def agent_visit(url, request): + if request.method != "POST": + raise Exception('unexpected method') + log.info('agent_visit url {}'.format(url)) + body = json.loads(request.body.decode('utf-8')) + if body['username'] != 'test-user': + raise Exception('unexpected username in body {!r}'.format(request.body)) + public_key = bakery.PublicKey.deserialize(body['public_key']) + ms = httpbakery.extract_macaroons(request.headers) + if len(ms) == 0: + b = bakery.Bakery(key=discharge_key) + m = b.oven.macaroon( + version=bakery.LATEST_VERSION, + expiry=datetime.utcnow() + timedelta(days=1), + caveats=[bakery.local_third_party_caveat( + public_key, + version=httpbakery.request_version(request.headers))], + ops=[bakery.Op(entity='agent', action='login')]) + content, headers = httpbakery.discharge_required_response( + m, '/', + 'test', + 'message') + resp = response(status_code=401, + content=content, + headers=headers) + return request.hooks['response'][0](resp) + + return { + 'status_code': 200, + 'content': { + 'agent_login': True + } + } + + @urlmatch(path='.*/wait$') + def wait(url, request): + class EmptyChecker(bakery.ThirdPartyCaveatChecker): + def check_third_party_caveat(self, ctx, info): + return [] + if InfoStorage.info is None: + self.fail('visit url has not been visited') + m = bakery.discharge( + checkers.AuthContext(), + InfoStorage.info.id, + InfoStorage.info.caveat, + discharge_key, + EmptyChecker(), + _DischargerLocator(), + ) + return { + 'status_code': 200, + 'content': { + 'Macaroon': m.to_dict() + } + } + + with HTTMock(server_get), \ + HTTMock(discharge), \ + HTTMock(visit), \ + HTTMock(wait), \ + HTTMock(agent_visit): + client = httpbakery.Client(interaction_methods=[ + agent.AgentInteractor( + agent.AuthInfo( + key=key, + agents=[agent.Agent(username='test-user', + url=u'http://0.3.2.1')], + ), + ), + ]) + resp = requests.get( + 'http://0.1.2.3/here', + cookies=client.cookies, + auth=client.auth(), + ) + self.assertEqual(resp.content, b'done') + + +agent_file = ''' +{ + "key": { + "public": "YAhRSsth3a36mRYqQGQaLiS4QJax0p356nd+B8x7UQE=", + "private": "CqoSgj06Zcgb4/S6RT4DpTjLAfKoznEY3JsShSjKJEU=" + }, + "agents": [{ + "url": "https://1.example.com/", + "username": "user-1" + }, { + "url": "https://2.example.com/discharger", + "username": "user-2" + }, { + "url": "http://0.3.2.1", + "username": "test-user" + }] +} +''' + +bad_key_agent_file = ''' +{ + "key": { + "public": "YAhRSsth3a36mRYqQGQaLiS4QJax0p356nd+B8x7UQE=", + "private": "CqoSgj06Zcgb4/S6RT4DpTjLAfKoznEY3JsShSjKJE==" + }, + "agents": [{ + "url": "https://1.example.com/", + "username": "user-1" + }, { + "url": "https://2.example.com/discharger", + "username": "user-2" + }] +} +''' + + +no_username_agent_file = ''' +{ + "key": { + "public": "YAhRSsth3a36mRYqQGQaLiS4QJax0p356nd+B8x7UQE=", + "private": "CqoSgj06Zcgb4/S6RT4DpTjLAfKoznEY3JsShSjKJEU=" + }, + "agents": [{ + "url": "https://1.example.com/" + }, { + "url": "https://2.example.com/discharger", + "username": "user-2" + }] +} +''' + + +class ThirdPartyCaveatCheckerF(bakery.ThirdPartyCaveatChecker): + def __init__(self, check): + self._check = check + + def check_third_party_caveat(self, ctx, info): + cond, arg = checkers.parse_caveat(info.condition) + return self._check(cond, arg) + +alwaysOK3rd = ThirdPartyCaveatCheckerF(lambda cond, arg: []) diff --git a/macaroonbakery/tests/test_authorizer.py b/macaroonbakery/tests/test_authorizer.py new file mode 100644 index 0000000..d5539b7 --- /dev/null +++ b/macaroonbakery/tests/test_authorizer.py @@ -0,0 +1,136 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +from unittest import TestCase + +import macaroonbakery.bakery as bakery +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 = [bakery.Op('a', 'x'), bakery.Op('b', 'x'), + bakery.Op('c', 'x'), bakery.Op('d', 'x')] + allowed, caveats = bakery.AuthorizerFunc(f).authorize( + checkers.AuthContext(), + bakery.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', + bakery.ACLAuthorizer(allow_public=True, get_acl=lambda x, y: []), + None, + [], + []), + ('identity that does not implement ACLIdentity; ' + 'user should be denied except for everyone group', + bakery.ACLAuthorizer( + allow_public=True, + get_acl=lambda ctx, op: [bakery.EVERYONE] if op.entity == 'a' else ['alice'], + ), + SimplestIdentity('bob'), + [bakery.Op(entity='a', action='a'), + bakery.Op(entity='b', action='b')], + [True, False]), + ('identity that does not implement ACLIdentity with user == Id; ' + 'user should be denied except for everyone group', + bakery.ACLAuthorizer( + allow_public=True, + get_acl=lambda ctx, op: [bakery.EVERYONE] if op.entity == 'a' else ['bob'], + ), + SimplestIdentity('bob'), + [bakery.Op(entity='a', action='a'), + bakery.Op(entity='b', action='b')], + [True, False]), + ('permission denied for everyone without AllowPublic', + bakery.ACLAuthorizer( + allow_public=False, + get_acl=lambda x, y: [bakery.EVERYONE], + ), + SimplestIdentity('bob'), + [bakery.Op(entity='a', action='a')], + [False]), + ('permission granted to anyone with no identity with AllowPublic', + bakery.ACLAuthorizer( + allow_public=True, + get_acl=lambda x, y: [bakery.EVERYONE], + ), + None, + [bakery.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 + + bakery.AuthorizerFunc(f).authorize( + ctx, bakery.SimpleIdentity('bob'), ['op1'] + ) + self.assertTrue(Visited.in_f) + + class TestIdentity(SimplestIdentity, bakery.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 [] + + bakery.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(bakery.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_bakery.py b/macaroonbakery/tests/test_bakery.py new file mode 100644 index 0000000..d242b2a --- /dev/null +++ b/macaroonbakery/tests/test_bakery.py @@ -0,0 +1,286 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +from unittest import TestCase + +import macaroonbakery.httpbakery as httpbakery +import requests +from mock import patch + +from httmock import HTTMock, response, urlmatch + +ID_PATH = 'http://example.com/someprotecteurl' + +json_macaroon = { + u'identifier': u'macaroon-identifier', + u'caveats': [ + { + u'cl': u'http://example.com/identity/v1/discharger', + u'vid': u'zgtQa88oS9UF45DlJniRaAUT4qqHhLxQzCeUU9N2O1Uu-' + u'yhFulgGbSA0zDGdkrq8YNQAxGiARA_-AGxyoh25kiTycb8u47pD', + u'cid': u'eyJUaGlyZFBhcnR5UHV' + }, { + u'cid': u'allow read-no-terms write' + }, { + u'cid': u'time-before 2158-07-19T14:29:14.312669464Z' + }], + u'location': u'charmstore', + u'signature': u'52d17cb11f5c84d58441bc0ffd7cc396' + u'5115374ce2fa473ecf06265b5d4d9e81' +} + +discharge_token = [{ + u'identifier': u'token-identifier===', + u'caveats': [{ + u'cid': u'declared username someone' + }, { + u'cid': u'time-before 2158-08-15T15:55:52.428319076Z' + }, { + u'cid': u'origin ' + }], + u'location': u'https://example.com/identity', + u'signature': u'5ae0e7a2abf806bdd92f510fcd3' + u'198f520691259abe76ffae5623dae048769ef' +}] + +discharged_macaroon = { + u'identifier': u'discharged-identifier=', + u'caveats': [{ + u'cid': u'declared uuid a1130b10-3deb-59b7-baf0-c2a3f83e7382' + }, { + u'cid': u'declared username someone' + }, { + u'cid': u'time-before 2158-07-19T15:55:52.432439055Z' + }], + u'location': u'', + u'signature': u'3513db5503ab17f9576760cd28' + u'ce658ce8bf6b43038255969fc3c1cd8b172345' +} + + +@urlmatch(path='.*/someprotecteurl') +def first_407_then_200(url, request): + if request.headers.get('cookie', '').startswith('macaroon-'): + return { + 'status_code': 200, + 'content': { + 'Value': 'some value' + } + } + else: + resp = response(status_code=407, + content={ + 'Info': { + 'Macaroon': json_macaroon, + 'MacaroonPath': '/', + 'CookieNameSuffix': 'test' + }, + 'Message': 'verification failed: no macaroon ' + 'cookies in request', + 'Code': 'macaroon discharge required' + }, + headers={'Content-Type': 'application/json'}) + return request.hooks['response'][0](resp) + + +@urlmatch(netloc='example.com:8000', path='.*/someprotecteurl') +def first_407_then_200_with_port(url, request): + if request.headers.get('cookie', '').startswith('macaroon-'): + return { + 'status_code': 200, + 'content': { + 'Value': 'some value' + } + } + else: + resp = response(status_code=407, + content={ + 'Info': { + 'Macaroon': json_macaroon, + 'MacaroonPath': '/', + 'CookieNameSuffix': 'test' + }, + 'Message': 'verification failed: no macaroon ' + 'cookies in request', + 'Code': 'macaroon discharge required' + }, + headers={'Content-Type': 'application/json'}, + request=request) + return request.hooks['response'][0](resp) + + +@urlmatch(path='.*/someprotecteurl') +def valid_200(url, request): + return { + 'status_code': 200, + 'content': { + 'Value': 'some value' + } + } + + +@urlmatch(path='.*/discharge') +def discharge_200(url, request): + return { + 'status_code': 200, + 'content': { + 'Macaroon': discharged_macaroon + } + } + + +@urlmatch(path='.*/discharge') +def discharge_401(url, request): + return { + 'status_code': 401, + 'content': { + 'Code': 'interaction required', + 'Info': { + 'VisitURL': 'http://example.com/visit', + 'WaitURL': 'http://example.com/wait' + } + }, + 'headers': { + 'WWW-Authenticate': 'Macaroon' + } + } + + +@urlmatch(path='.*/visit') +def visit_200(url, request): + return { + 'status_code': 200, + 'content': { + 'interactive': '/visit' + } + } + + +@urlmatch(path='.*/wait') +def wait_after_401(url, request): + if request.url != 'http://example.com/wait': + return {'status_code': 500} + + return { + 'status_code': 200, + 'content': { + 'DischargeToken': discharge_token, + 'Macaroon': discharged_macaroon + } + } + + +@urlmatch(path='.*/wait') +def wait_on_error(url, request): + return { + 'status_code': 500, + 'content': { + 'DischargeToken': discharge_token, + 'Macaroon': discharged_macaroon + } + } + + +class TestBakery(TestCase): + + def assert_cookie_security(self, cookies, name, secure): + for cookie in cookies: + if cookie.name == name: + assert cookie.secure == secure + break + else: + assert False, 'no cookie named {} found in jar'.format(name) + + def test_discharge(self): + client = httpbakery.Client() + with HTTMock(first_407_then_200), HTTMock(discharge_200): + resp = requests.get(ID_PATH, + cookies=client.cookies, + auth=client.auth()) + resp.raise_for_status() + assert 'macaroon-test' in client.cookies.keys() + self.assert_cookie_security(client.cookies, 'macaroon-test', + secure=False) + + @patch('webbrowser.open') + def test_407_then_401_on_discharge(self, mock_open): + client = httpbakery.Client() + with HTTMock(first_407_then_200), HTTMock(discharge_401), \ + HTTMock(wait_after_401): + resp = requests.get( + ID_PATH, + cookies=client.cookies, + auth=client.auth(), + ) + resp.raise_for_status() + mock_open.assert_called_once_with(u'http://example.com/visit', new=1) + assert 'macaroon-test' in client.cookies.keys() + + @patch('webbrowser.open') + def test_407_then_error_on_wait(self, mock_open): + client = httpbakery.Client() + with HTTMock(first_407_then_200), HTTMock(discharge_401),\ + HTTMock(wait_on_error): + with self.assertRaises(httpbakery.InteractionError) as exc: + requests.get( + ID_PATH, + cookies=client.cookies, + auth=client.auth(), + ) + self.assertEqual(str(exc.exception), + 'cannot start interactive session: cannot get ' + 'http://example.com/wait') + mock_open.assert_called_once_with(u'http://example.com/visit', new=1) + + def test_407_then_no_interaction_methods(self): + client = httpbakery.Client(interaction_methods=[]) + with HTTMock(first_407_then_200), HTTMock(discharge_401): + with self.assertRaises(httpbakery.InteractionError) as exc: + requests.get( + ID_PATH, + cookies=client.cookies, + auth=client.auth(), + ) + self.assertEqual(str(exc.exception), + 'cannot start interactive session: interaction ' + 'required but not possible') + + def test_407_then_unknown_interaction_methods(self): + class UnknownInteractor(httpbakery.Interactor): + def kind(self): + return 'unknown' + client = httpbakery.Client(interaction_methods=[UnknownInteractor()]) + with HTTMock(first_407_then_200), HTTMock(discharge_401),\ + HTTMock(visit_200): + with self.assertRaises(httpbakery.InteractionError) as exc: + requests.get( + ID_PATH, + cookies=client.cookies, + auth=client.auth(), + ) + self.assertEqual( + str(exc.exception), + 'cannot start interactive session: no methods supported; ' + 'supported [unknown]; provided [interactive]' + ) + + def test_cookie_with_port(self): + client = httpbakery.Client() + with HTTMock(first_407_then_200_with_port): + with HTTMock(discharge_200): + resp = requests.get('http://example.com:8000/someprotecteurl', + cookies=client.cookies, + auth=client.auth()) + resp.raise_for_status() + assert 'macaroon-test' in client.cookies.keys() + + def test_secure_cookie_for_https(self): + client = httpbakery.Client() + with HTTMock(first_407_then_200_with_port), HTTMock(discharge_200): + resp = requests.get( + 'https://example.com:8000/someprotecteurl', + cookies=client.cookies, + auth=client.auth()) + resp.raise_for_status() + assert 'macaroon-test' in client.cookies.keys() + self.assert_cookie_security(client.cookies, 'macaroon-test', + secure=True) diff --git a/macaroonbakery/tests/test_checker.py b/macaroonbakery/tests/test_checker.py new file mode 100644 index 0000000..6b61768 --- /dev/null +++ b/macaroonbakery/tests/test_checker.py @@ -0,0 +1,946 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +import base64 +import json +from collections import namedtuple +from datetime import timedelta +from unittest import TestCase + +import macaroonbakery.bakery as bakery +import macaroonbakery.checkers as checkers +import pymacaroons +from macaroonbakery.tests.common import epoch, test_checker, test_context +from pymacaroons.verifier import FirstPartyCaveatVerifierDelegate, Verifier + + +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( + {bakery.Op(entity='something', action='read'): + {bakery.EVERYONE}}) + ts = _Service('myservice', auth, ids, locator) + client = _Client(locator) + auth_info = client.do(test_context, ts, [ + bakery.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 = bakery.ClosedAuthorizer() + ts = _Service('myservice', auth, ids, locator) + client = _Client(locator) + ctx = test_context.with_value(_DISCHARGE_USER_KEY, 'bob') + with self.assertRaises(bakery.PermissionDenied): + client.do(ctx, ts, [bakery.Op(entity='something', action='read')]) + + def test_authorize_with_authentication_required(self): + locator = _DischargerLocator() + ids = _IdService('ids', locator, self) + auth = _OpAuthorizer( + {bakery.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, [bakery.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( + { + bakery.Op(entity='something', action='read'): {'bob'}, + bakery.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, [ + bakery.Op(entity='something', action='read'), + bakery.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( + {bakery.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, [bakery.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], [ + bakery.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({ + bakery.Op(entity='e1', action='read'): {'bob'}, + bakery.Op(entity='e2', action='read'): {'bob'}, + bakery.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, [ + bakery.Op(entity='e1', action='read'), + bakery.Op(entity='e2', action='read'), + bakery.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], [ + bakery.Op(entity='e1', action='read'), + bakery.Op(entity='e2', action='read'), + bakery.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], [ + bakery.Op(entity='e2', action='read'), + bakery.Op(entity='e3', action='read'), + ]) + ts.do(test_context, [m], + [bakery.Op(entity='e3', action='read')]) + + def test_multiple_capabilities(self): + locator = _DischargerLocator() + ids = _IdService('ids', locator, self) + auth = _OpAuthorizer({ + bakery.Op(entity='e1', action='read'): {'alice'}, + bakery.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, [ + bakery.Op(entity='e1', action='read'), + ]) + ctx = test_context.with_value(_DISCHARGE_USER_KEY, 'bob') + m2 = _Client(locator).discharged_capability(ctx, ts, + [bakery.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], [ + bakery.Op(entity='e1', action='read'), + bakery.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({ + bakery.Op(entity='e1', action='read'): {'alice'}, + bakery.Op(entity='e2', action='read'): {'bob'}, + bakery.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, [ + bakery.Op(entity='e1', action='read'), + bakery.Op(entity='e3', action='read'), + ]) + ctx = test_context.with_value(_DISCHARGE_USER_KEY, 'bob') + m2 = _Client(locator).discharged_capability( + ctx, ts, [bakery.Op(entity='e2', action='read')]) + + m = ts.capability(test_context, [m1, m2], [ + bakery.Op(entity='e1', action='read'), + bakery.Op(entity='e2', action='read'), + bakery.Op(entity='e3', action='read'), + ]) + ts.do(test_context, [[m.macaroon]], [ + bakery.Op(entity='e1', action='read'), + bakery.Op(entity='e2', action='read'), + bakery.Op(entity='e3', action='read'), + ]) + + def test_partially_authorized_request(self): + locator = _DischargerLocator() + ids = _IdService('ids', locator, self) + auth = _OpAuthorizer({ + bakery.Op(entity='e1', action='read'): {'alice'}, + bakery.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, [ + bakery.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, [ + bakery.Op(entity='e1', action='read'), + bakery.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 == bakery.Op(entity='something', action='read')): + return True, [checkers.Caveat(condition='question', + location='other third party')] + return False, None + + auth = bakery.AuthorizerFunc(authorize_with_tp_discharge) + ts = _Service('myservice', auth, ids, locator) + + class _LocalDischargeChecker(bakery.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=bakery.generate_key(), + checker=_LocalDischargeChecker(), + locator=locator, + ) + client = _Client(locator) + ctx = test_context.with_value(_DISCHARGE_USER_KEY, 'bob') + client.do(ctx, ts, [bakery.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({ + bakery.Op(entity='e1', action='read'): {'alice'}, + bakery.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, [bakery.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, [bakery.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, [ + bakery.Op(entity='e1', action='read'), + bakery.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({ + bakery.Op(entity='e1', action='read'): {'alice'}, + bakery.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, [bakery.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, [bakery.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, [bakery.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 = bakery.ClosedAuthorizer() + ts = _Service('myservice', auth, ids, locator) + + ctx = test_context.with_value(_DISCHARGE_USER_KEY, 'bob') + auth_info = _Client(locator).do(ctx, ts, [bakery.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( + { + bakery.Op(entity='e1', action='read'): {'alice'}, + bakery.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, [ + bakery.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, [ + bakery.LOGIN_OP, + bakery.Op(entity='e1', action='read'), + bakery.Op(entity='e1', action='read') + ] + ) + self.assertEqual(len(self._discharges), 0) + + # Log in as bob. + _, err = client.do(ctx, ts, [bakery.LOGIN_OP]) + + # All the previous actions should now be allowed. + auth_info, allowed = client.do_any(ctx, ts, [ + bakery.LOGIN_OP, + bakery.Op(entity='e1', action='read'), + bakery.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({ + bakery.Op(entity='e1', action='read'): {'sherlock'}, + bakery.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, [bakery.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', bakery.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, [bakery.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({ + bakery.Op(entity='e1', action='read'): {'bob'}, + bakery.Op(entity='e1', action='write'): {'bob'}, + bakery.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, [ + bakery.Op(entity='e1', action='read'), + bakery.Op(entity='e1', action='write'), + bakery.Op(entity='e2', action='read'), + ]) + + # Sanity check that we can do a write. + ts.do(test_context, [[m.macaroon]], + [bakery.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]], [ + bakery.Op(entity='e1', action='read'), + bakery.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]], [ + bakery.Op(entity='e1', action='write'), + ]) + + def test_operation_deny_caveat(self): + locator = _DischargerLocator() + ids = _IdService('ids', locator, self) + auth = _OpAuthorizer({ + bakery.Op(entity='e1', action='read'): {'bob'}, + bakery.Op(entity='e1', action='write'): {'bob'}, + bakery.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, [ + bakery.Op(entity='e1', action='read'), + bakery.Op(entity='e1', action='write'), + bakery.Op(entity='e2', action='read'), + ]) + + # Sanity check that we can do a write. + ts.do(test_context, [[m.macaroon]], [ + bakery.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]], [ + bakery.Op(entity='e1', action='read'), + bakery.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]], [ + bakery.Op(entity='e1', action='write')]) + + def test_duplicate_login_macaroons(self): + locator = _DischargerLocator() + ids = _IdService('ids', locator, self) + auth = bakery.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, [bakery.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, [bakery.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, [bakery.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, [bakery.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 = bakery.Checker( + macaroon_opstore=_MacaroonStoreWithError()) + m = pymacaroons.Macaroon(version=pymacaroons.MACAROON_V2) + with self.assertRaises(bakery.AuthInitError): + checker.auth([m]).allow(test_context, [bakery.LOGIN_OP]) + + +class _DischargerLocator(bakery.ThirdPartyLocator): + 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 bakery.ThirdPartyInfo( + public_key=d._key.public_key, + version=bakery.LATEST_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(bakery.IdentityClient, + bakery.ThirdPartyCaveatChecker): + def __init__(self, location, locator, test_class): + self._location = location + self._test = test_class + key = bakery.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 bakery.CaveatNotRecognizedError( + 'third party condition not recognized') + + username = ctx.get(_DISCHARGE_USER_KEY, '') + if username == '': + raise bakery.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 bakery.IdentityError('no username declared') + return bakery.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 bakery.discharge( + ctx, + key=self._key, + id=cav.caveat_id, + caveat=payload, + checker=self._checker, + locator=self._locator, + ) + + +class _OpAuthorizer(bakery.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 bakery.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 = bakery.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 = bakery.Macaroon( + root_key=root_key, id=data, location='', + version=bakery.LATEST_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 bakery.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(bakery.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(bakery.generate_key(), locator) + self._checker = bakery.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 bakery.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 bakery.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 bakery.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] == bakery.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 = bakery.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(cav, payload): + 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, payload) + + return bakery.discharge_all(m, get_discharge) + + +class _BasicAuthIdService(bakery.IdentityClient): + def identity_from_context(self, ctx): + user, pwd = _basic_auth_from_context(ctx) + if user != 'sherlock' or pwd != 'holmes': + return None, None + return bakery.SimpleIdentity(user), None + + def declared_identity(self, ctx, declared): + raise bakery.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..2628153 --- /dev/null +++ b/macaroonbakery/tests/test_checkers.py @@ -0,0 +1,354 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +from datetime import datetime, timedelta +from unittest import TestCase + +import macaroonbakery.checkers as checkers +import six +from pymacaroons import MACAROON_V2, Macaroon + +# 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 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_client.py b/macaroonbakery/tests/test_client.py new file mode 100644 index 0000000..6437a54 --- /dev/null +++ b/macaroonbakery/tests/test_client.py @@ -0,0 +1,682 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +import base64 +import datetime +import json +import threading + +import macaroonbakery.bakery as bakery +import macaroonbakery.checkers as checkers +import macaroonbakery.httpbakery as httpbakery +import pymacaroons +import requests +import macaroonbakery._utils as utils + +from fixtures import ( + EnvironmentVariable, + TestWithFixtures, +) +from httmock import HTTMock, urlmatch +from six.moves.urllib.parse import parse_qs +from six.moves.urllib.request import Request + +try: + from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler +except ImportError: + from http.server import HTTPServer, BaseHTTPRequestHandler + +AGES = datetime.datetime.utcnow() + datetime.timedelta(days=1) +TEST_OP = bakery.Op(entity='test', action='test') + + +class TestClient(TestWithFixtures): + def setUp(self): + super(TestClient, self).setUp() + # http_proxy would cause requests to talk to the proxy, which is + # unlikely to know how to talk to the test server. + self.useFixture(EnvironmentVariable('http_proxy')) + self.useFixture(EnvironmentVariable('HTTP_PROXY')) + + def test_single_service_first_party(self): + b = new_bakery('loc', None, None) + + def handler(*args): + GetHandler(b, None, None, None, None, AGES, *args) + try: + httpd = HTTPServer(('', 0), handler) + thread = threading.Thread(target=httpd.serve_forever) + thread.start() + srv_macaroon = b.oven.macaroon( + version=bakery.LATEST_VERSION, expiry=AGES, + caveats=None, ops=[TEST_OP]) + self.assertEquals(srv_macaroon.macaroon.location, 'loc') + client = httpbakery.Client() + client.cookies.set_cookie(requests.cookies.create_cookie( + 'macaroon-test', base64.b64encode(json.dumps([ + srv_macaroon.to_dict().get('m') + ]).encode('utf-8')).decode('utf-8') + )) + resp = requests.get( + url='http://' + httpd.server_address[0] + ':' + + str(httpd.server_address[1]), + cookies=client.cookies, auth=client.auth()) + resp.raise_for_status() + self.assertEquals(resp.text, 'done') + finally: + httpd.shutdown() + + def test_single_service_third_party(self): + class _DischargerLocator(bakery.ThirdPartyLocator): + def __init__(self): + self.key = bakery.generate_key() + + def third_party_info(self, loc): + if loc == 'http://1.2.3.4': + return bakery.ThirdPartyInfo( + public_key=self.key.public_key, + version=bakery.LATEST_VERSION, + ) + + d = _DischargerLocator() + b = new_bakery('loc', d, None) + + @urlmatch(path='.*/discharge') + def discharge(url, request): + self.assertEqual(url.path, '/discharge') + qs = parse_qs(request.body) + content = {q: qs[q][0] for q in qs} + m = httpbakery.discharge(checkers.AuthContext(), content, d.key, d, + alwaysOK3rd) + return { + 'status_code': 200, + 'content': { + 'Macaroon': m.to_dict() + } + } + + def handler(*args): + GetHandler(b, 'http://1.2.3.4', None, None, None, AGES, *args) + try: + httpd = HTTPServer(('', 0), handler) + server_url = 'http://' + httpd.server_address[0] + ':' + str(httpd.server_address[1]) + thread = threading.Thread(target=httpd.serve_forever) + thread.start() + client = httpbakery.Client() + with HTTMock(discharge): + resp = requests.get( + url=server_url, + cookies=client.cookies, + auth=client.auth()) + resp.raise_for_status() + self.assertEquals(resp.text, 'done') + finally: + httpd.shutdown() + + def test_single_service_third_party_with_path(self): + class _DischargerLocator(bakery.ThirdPartyLocator): + def __init__(self): + self.key = bakery.generate_key() + + def third_party_info(self, loc): + if loc == 'http://1.2.3.4/some/path': + return bakery.ThirdPartyInfo( + public_key=self.key.public_key, + version=bakery.LATEST_VERSION, + ) + + d = _DischargerLocator() + b = new_bakery('loc', d, None) + + @urlmatch(path='.*/discharge') + def discharge(url, request): + self.assertEqual(url.path, '/some/path/discharge') + qs = parse_qs(request.body) + content = {q: qs[q][0] for q in qs} + m = httpbakery.discharge(checkers.AuthContext(), content, d.key, d, + alwaysOK3rd) + return { + 'status_code': 200, + 'content': { + 'Macaroon': m.to_dict() + } + } + + def handler(*args): + GetHandler(b, 'http://1.2.3.4/some/path', None, None, None, AGES, *args) + try: + httpd = HTTPServer(('', 0), handler) + server_url = 'http://' + httpd.server_address[0] + ':' + str(httpd.server_address[1]) + thread = threading.Thread(target=httpd.serve_forever) + thread.start() + client = httpbakery.Client() + with HTTMock(discharge): + resp = requests.get( + url=server_url, + cookies=client.cookies, + auth=client.auth()) + resp.raise_for_status() + self.assertEquals(resp.text, 'done') + finally: + httpd.shutdown() + + def test_single_service_third_party_version_1_caveat(self): + class _DischargerLocator(bakery.ThirdPartyLocator): + def __init__(self): + self.key = bakery.generate_key() + + def third_party_info(self, loc): + if loc == 'http://1.2.3.4': + return bakery.ThirdPartyInfo( + public_key=self.key.public_key, + version=bakery.VERSION_1, + ) + + d = _DischargerLocator() + b = new_bakery('loc', d, None) + + @urlmatch(path='.*/discharge') + def discharge(url, request): + qs = parse_qs(request.body) + content = {q: qs[q][0] for q in qs} + m = httpbakery.discharge(checkers.AuthContext(), content, d.key, d, + alwaysOK3rd) + return { + 'status_code': 200, + 'content': { + 'Macaroon': m.to_dict() + } + } + + def handler(*args): + GetHandler(b, 'http://1.2.3.4', None, None, None, AGES, *args) + try: + httpd = HTTPServer(('', 0), handler) + server_url = 'http://' + httpd.server_address[0] + ':' + str(httpd.server_address[1]) + thread = threading.Thread(target=httpd.serve_forever) + thread.start() + client = httpbakery.Client() + with HTTMock(discharge): + resp = requests.get( + url=server_url, + cookies=client.cookies, + auth=client.auth()) + resp.raise_for_status() + self.assertEquals(resp.text, 'done') + finally: + httpd.shutdown() + + def test_cookie_domain_host_not_fqdn(self): + # See + # https://github.com/go-macaroon-bakery/py-macaroon-bakery/issues/53 + + b = new_bakery('loc', None, None) + + def handler(*args): + GetHandler(b, None, None, None, None, AGES, *args) + try: + httpd = HTTPServer(('', 0), handler) + thread = threading.Thread(target=httpd.serve_forever) + thread.start() + srv_macaroon = b.oven.macaroon( + version=bakery.LATEST_VERSION, expiry=AGES, + caveats=None, ops=[TEST_OP]) + self.assertEquals(srv_macaroon.macaroon.location, 'loc') + client = httpbakery.Client() + # Note: by using "localhost" instead of the presumably numeric address held + # in httpd.server_address, we're triggering the no-FQDN logic in the cookie + # code. + resp = requests.get( + url='http://localhost:' + str(httpd.server_address[1]), + cookies=client.cookies, auth=client.auth()) + resp.raise_for_status() + self.assertEquals(resp.text, 'done') + except httpbakery.BakeryException: + pass # interacion required exception is expected + finally: + httpd.shutdown() + + # the cookie has the .local domain appended + [cookie] = client.cookies + self.assertEqual(cookie.name, 'macaroon-test') + self.assertEqual(cookie.domain, 'localhost.local') + + def test_single_party_with_header(self): + b = new_bakery('loc', None, None) + + def handler(*args): + GetHandler(b, None, None, None, None, AGES, *args) + try: + httpd = HTTPServer(('', 0), handler) + thread = threading.Thread(target=httpd.serve_forever) + thread.start() + srv_macaroon = b.oven.macaroon( + version=bakery.LATEST_VERSION, + expiry=AGES, caveats=None, ops=[TEST_OP]) + self.assertEquals(srv_macaroon.macaroon.location, 'loc') + headers = { + 'Macaroons': base64.b64encode(json.dumps([ + srv_macaroon.to_dict().get('m') + ]).encode('utf-8')) + } + resp = requests.get( + url='http://' + httpd.server_address[0] + ':' + + str(httpd.server_address[1]), + headers=headers) + resp.raise_for_status() + self.assertEquals(resp.text, 'done') + finally: + httpd.shutdown() + + def test_expiry_cookie_is_set(self): + class _DischargerLocator(bakery.ThirdPartyLocator): + def __init__(self): + self.key = bakery.generate_key() + + def third_party_info(self, loc): + if loc == 'http://1.2.3.4': + return bakery.ThirdPartyInfo( + public_key=self.key.public_key, + version=bakery.LATEST_VERSION, + ) + + d = _DischargerLocator() + b = new_bakery('loc', d, None) + + @urlmatch(path='.*/discharge') + def discharge(url, request): + qs = parse_qs(request.body) + content = {q: qs[q][0] for q in qs} + m = httpbakery.discharge(checkers.AuthContext(), content, d.key, d, + alwaysOK3rd) + return { + 'status_code': 200, + 'content': { + 'Macaroon': m.to_dict() + } + } + + ages = datetime.datetime.utcnow() + datetime.timedelta(days=1) + + def handler(*args): + GetHandler(b, 'http://1.2.3.4', None, None, None, ages, *args) + try: + httpd = HTTPServer(('', 0), handler) + thread = threading.Thread(target=httpd.serve_forever) + thread.start() + client = httpbakery.Client() + with HTTMock(discharge): + resp = requests.get( + url='http://' + httpd.server_address[0] + ':' + + str(httpd.server_address[1]), + cookies=client.cookies, + auth=client.auth()) + resp.raise_for_status() + m = bakery.Macaroon.from_dict(json.loads( + base64.b64decode(client.cookies.get('macaroon-test')).decode('utf-8'))[0]) + t = checkers.macaroons_expiry_time( + checkers.Namespace(), [m.macaroon]) + self.assertEquals(ages, t) + self.assertEquals(resp.text, 'done') + finally: + httpd.shutdown() + + def test_expiry_cookie_set_in_past(self): + class _DischargerLocator(bakery.ThirdPartyLocator): + def __init__(self): + self.key = bakery.generate_key() + + def third_party_info(self, loc): + if loc == 'http://1.2.3.4': + return bakery.ThirdPartyInfo( + public_key=self.key.public_key, + version=bakery.LATEST_VERSION, + ) + + d = _DischargerLocator() + b = new_bakery('loc', d, None) + + @urlmatch(path='.*/discharge') + def discharge(url, request): + qs = parse_qs(request.body) + content = {q: qs[q][0] for q in qs} + m = httpbakery.discharge(checkers.AuthContext(), content, d.key, d, + alwaysOK3rd) + return { + 'status_code': 200, + 'content': { + 'Macaroon': m.to_dict() + } + } + + ages = datetime.datetime.utcnow() - datetime.timedelta(days=1) + + def handler(*args): + GetHandler(b, 'http://1.2.3.4', None, None, None, ages, *args) + try: + httpd = HTTPServer(('', 0), handler) + thread = threading.Thread(target=httpd.serve_forever) + thread.start() + client = httpbakery.Client() + with HTTMock(discharge): + with self.assertRaises(httpbakery.BakeryException) as ctx: + requests.get( + url='http://' + httpd.server_address[0] + ':' + + str(httpd.server_address[1]), + cookies=client.cookies, + auth=client.auth()) + self.assertEqual(ctx.exception.args[0], + 'too many (3) discharge requests') + finally: + httpd.shutdown() + + def test_too_many_discharge(self): + class _DischargerLocator(bakery.ThirdPartyLocator): + def __init__(self): + self.key = bakery.generate_key() + + def third_party_info(self, loc): + if loc == 'http://1.2.3.4': + return bakery.ThirdPartyInfo( + public_key=self.key.public_key, + version=bakery.LATEST_VERSION, + ) + + d = _DischargerLocator() + b = new_bakery('loc', d, None) + + @urlmatch(path='.*/discharge') + def discharge(url, request): + wrong_macaroon = bakery.Macaroon( + root_key=b'some key', id=b'xxx', + location='some other location', + version=bakery.VERSION_0) + return { + 'status_code': 200, + 'content': { + 'Macaroon': wrong_macaroon.to_dict() + } + } + + def handler(*args): + GetHandler(b, 'http://1.2.3.4', None, None, None, AGES, *args) + try: + httpd = HTTPServer(('', 0), handler) + thread = threading.Thread(target=httpd.serve_forever) + thread.start() + client = httpbakery.Client() + with HTTMock(discharge): + with self.assertRaises(httpbakery.BakeryException) as ctx: + requests.get( + url='http://' + httpd.server_address[0] + ':' + + str(httpd.server_address[1]), + cookies=client.cookies, + auth=client.auth()) + self.assertEqual(ctx.exception.args[0], + 'too many (3) discharge requests') + finally: + httpd.shutdown() + + def test_third_party_discharge_refused(self): + class _DischargerLocator(bakery.ThirdPartyLocator): + def __init__(self): + self.key = bakery.generate_key() + + def third_party_info(self, loc): + if loc == 'http://1.2.3.4': + return bakery.ThirdPartyInfo( + public_key=self.key.public_key, + version=bakery.LATEST_VERSION, + ) + + def check(cond, arg): + raise bakery.ThirdPartyCaveatCheckFailed('boo! cond' + cond) + + d = _DischargerLocator() + b = new_bakery('loc', d, None) + + @urlmatch(path='.*/discharge') + def discharge(url, request): + qs = parse_qs(request.body) + content = {q: qs[q][0] for q in qs} + httpbakery.discharge(checkers.AuthContext(), content, d.key, d, + ThirdPartyCaveatCheckerF(check)) + + def handler(*args): + GetHandler(b, 'http://1.2.3.4', None, None, None, AGES, *args) + try: + httpd = HTTPServer(('', 0), handler) + thread = threading.Thread(target=httpd.serve_forever) + thread.start() + client = httpbakery.Client() + with HTTMock(discharge): + with self.assertRaises(bakery.ThirdPartyCaveatCheckFailed): + requests.get( + url='http://' + httpd.server_address[0] + ':' + + str(httpd.server_address[1]), + cookies=client.cookies, + auth=client.auth()) + finally: + httpd.shutdown() + + def test_discharge_with_interaction_required_error(self): + class _DischargerLocator(bakery.ThirdPartyLocator): + def __init__(self): + self.key = bakery.generate_key() + + def third_party_info(self, loc): + if loc == 'http://1.2.3.4': + return bakery.ThirdPartyInfo( + public_key=self.key.public_key, + version=bakery.LATEST_VERSION, + ) + d = _DischargerLocator() + b = new_bakery('loc', d, None) + + @urlmatch(path='.*/discharge') + def discharge(url, request): + return { + 'status_code': 401, + 'content': { + 'Code': httpbakery.ERR_INTERACTION_REQUIRED, + 'Message': 'interaction required', + 'Info': { + 'WaitURL': 'http://0.1.2.3/', + 'VisitURL': 'http://0.1.2.3/', + }, + } + } + + def handler(*args): + GetHandler(b, 'http://1.2.3.4', None, None, None, AGES, *args) + + try: + httpd = HTTPServer(('', 0), handler) + thread = threading.Thread(target=httpd.serve_forever) + thread.start() + + class MyInteractor(httpbakery.LegacyInteractor): + def legacy_interact(self, ctx, location, visit_url): + raise httpbakery.InteractionError('cannot visit') + + def interact(self, ctx, location, interaction_required_err): + pass + + def kind(self): + return httpbakery.WEB_BROWSER_INTERACTION_KIND + + client = httpbakery.Client(interaction_methods=[MyInteractor()]) + + with HTTMock(discharge): + with self.assertRaises(httpbakery.InteractionError): + requests.get( + 'http://' + httpd.server_address[0] + ':' + str( + httpd.server_address[1]), + cookies=client.cookies, + auth=client.auth()) + finally: + httpd.shutdown() + + def test_extract_macaroons_from_request(self): + def encode_macaroon(m): + macaroons = '[' + utils.macaroon_to_json_string(m) + ']' + return base64.urlsafe_b64encode(utils.to_bytes(macaroons)).decode('ascii') + + req = Request('http://example.com') + m1 = pymacaroons.Macaroon(version=pymacaroons.MACAROON_V2, identifier='one') + req.add_header('Macaroons', encode_macaroon(m1)) + m2 = pymacaroons.Macaroon(version=pymacaroons.MACAROON_V2, identifier='two') + jar = requests.cookies.RequestsCookieJar() + jar.set_cookie(utils.cookie( + name='macaroon-auth', + value=encode_macaroon(m2), + url='http://example.com', + )) + jar.set_cookie(utils.cookie( + name='macaroon-empty', + value='', + url='http://example.com', + )) + jar.add_cookie_header(req) + + macaroons = httpbakery.extract_macaroons(req) + self.assertEquals(len(macaroons), 2) + macaroons.sort(key=lambda ms: ms[0].identifier) + self.assertEquals(macaroons[0][0].identifier, m1.identifier) + self.assertEquals(macaroons[1][0].identifier, m2.identifier) + + def test_handle_error_cookie_path(self): + macaroon = bakery.Macaroon( + root_key=b'some key', id=b'xxx', + location='some location', + version=bakery.VERSION_0) + info = { + 'Macaroon': macaroon.to_dict(), + 'MacaroonPath': '.', + 'CookieNameSuffix': 'test' + } + error = httpbakery.Error( + code=407, + message='error', + version=bakery.LATEST_VERSION, + info=httpbakery.ErrorInfo.from_dict(info)) + client = httpbakery.Client() + client.handle_error(error, 'http://example.com/some/path') + [cookie] = client.cookies + self.assertEqual(cookie.path, "/some/") + + +class GetHandler(BaseHTTPRequestHandler): + '''A mock HTTP server that serves a GET request''' + def __init__(self, bakery, auth_location, mutate_error, + caveats, version, expiry, *args): + ''' + @param bakery used to check incoming requests and macaroons + for discharge-required errors. + @param auth_location holds the location of any 3rd party + authorizer. If this is not None, a 3rd party caveat will be + added addressed to this location. + @param mutate_error if non None, will be called with any + discharge-required error before responding to the client. + @param caveats called to get caveats to add to the returned + macaroon. + @param version holds the version of the bakery that the + server will purport to serve. + @param expiry holds the expiry for the macaroon that will be created + in _write_discharge_error + ''' + self._bakery = bakery + self._auth_location = auth_location + self._mutate_error = mutate_error + self._caveats = caveats + self._server_version = version + self._expiry = expiry + BaseHTTPRequestHandler.__init__(self, *args) + + def do_GET(self): + '''do_GET implements a handler for the HTTP GET method''' + ctx = checkers.AuthContext() + auth_checker = self._bakery.checker.auth( + httpbakery.extract_macaroons(self.headers)) + try: + auth_checker.allow(ctx, [TEST_OP]) + except (bakery.PermissionDenied, + bakery.VerificationError) as exc: + return self._write_discharge_error(exc) + self.send_response(200) + self.end_headers() + content_len = int(self.headers.get('content-length', 0)) + content = 'done' + if self.path != '/no-body'and content_len > 0: + body = self.rfile.read(content_len) + content = content + ' ' + body + self.wfile.write(content.encode('utf-8')) + return + + def _write_discharge_error(self, exc): + version = httpbakery.request_version(self.headers) + if version < bakery.LATEST_VERSION: + self._server_version = version + + caveats = [] + if self._auth_location != '': + caveats = [ + checkers.Caveat(location=self._auth_location, + condition='is-ok') + ] + if self._caveats is not None: + caveats.extend(self._caveats) + + m = self._bakery.oven.macaroon( + version=bakery.LATEST_VERSION, expiry=self._expiry, + caveats=caveats, ops=[TEST_OP]) + + content, headers = httpbakery.discharge_required_response( + m, '/', 'test', exc.args[0]) + self.send_response(401) + for h in headers: + self.send_header(h, headers[h]) + self.send_header('Connection', 'close') + self.end_headers() + self.wfile.write(content) + + +def new_bakery(location, locator, checker): + '''Return a new bakery instance. + @param location Location of the bakery {str}. + @param locator Locator for third parties {ThirdPartyLocator or None} + @param checker Caveat checker {FirstPartyCaveatChecker or None} + @return {Bakery} + ''' + if checker is None: + c = checkers.Checker() + c.namespace().register('testns', '') + c.register('is', 'testns', check_is_something) + checker = c + key = bakery.generate_key() + return bakery.Bakery( + location=location, + locator=locator, + key=key, + checker=checker, + ) + + +def is_something_caveat(): + return checkers.Caveat(condition='is something', namespace='testns') + + +def check_is_something(ctx, cond, arg): + if arg != 'something': + return '{} doesn\'t match "something"'.format(arg) + return None + + +class ThirdPartyCaveatCheckerF(bakery.ThirdPartyCaveatChecker): + def __init__(self, check): + self._check = check + + def check_third_party_caveat(self, ctx, info): + cond, arg = checkers.parse_caveat(info.condition) + return self._check(cond, arg) + +alwaysOK3rd = ThirdPartyCaveatCheckerF(lambda cond, arg: []) diff --git a/macaroonbakery/tests/test_codec.py b/macaroonbakery/tests/test_codec.py new file mode 100644 index 0000000..d82a794 --- /dev/null +++ b/macaroonbakery/tests/test_codec.py @@ -0,0 +1,196 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +import base64 +from unittest import TestCase + +import macaroonbakery.bakery as bakery +import macaroonbakery.checkers as checkers +import nacl.public +import six +from macaroonbakery.bakery import _codec as codec + + +class TestCodec(TestCase): + def setUp(self): + self.fp_key = bakery.generate_key() + self.tp_key = bakery.generate_key() + + def test_v1_round_trip(self): + tp_info = bakery.ThirdPartyInfo( + version=bakery.VERSION_1, + public_key=self.tp_key.public_key) + cid = bakery.encode_caveat( + 'is-authenticated-user', + b'a random string', + tp_info, + self.fp_key, + None) + res = bakery.decode_caveat(self.tp_key, cid) + self.assertEquals(res, bakery.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.VERSION_1, + id=None, + namespace=bakery.legacy_namespace() + )) + + def test_v2_round_trip(self): + tp_info = bakery.ThirdPartyInfo( + version=bakery.VERSION_2, + public_key=self.tp_key.public_key) + cid = bakery.encode_caveat( + 'is-authenticated-user', + b'a random string', + tp_info, + self.fp_key, + None) + res = bakery.decode_caveat(self.tp_key, cid) + self.assertEquals(res, bakery.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.VERSION_2, + id=None, + namespace=bakery.legacy_namespace() + )) + + def test_v3_round_trip(self): + tp_info = bakery.ThirdPartyInfo( + version=bakery.VERSION_3, + public_key=self.tp_key.public_key) + ns = checkers.Namespace() + ns.register('testns', 'x') + cid = bakery.encode_caveat( + 'is-authenticated-user', + b'a random string', + tp_info, + self.fp_key, + ns) + res = bakery.decode_caveat(self.tp_key, cid) + self.assertEquals(res, bakery.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.VERSION_3, + id=None, + namespace=ns + )) + + def test_empty_caveat_id(self): + with self.assertRaises(bakery.VerificationError) as context: + bakery.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 = bakery.PrivateKey( + nacl.public.PrivateKey(base64.b64decode( + 'TSpvLpQkRj+T3JXnsW2n43n5zP/0X4zn0RvDiWC3IJ0='))) + fp_key = bakery.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' + 'JzdFBhcnR5UHVibGljS2V5IjoiSDlqSFJqSUxidXppa1VKd2o' + '5VGtDWk9qeW5oVmtTdHVsaUFRT2d6Y0NoZz0iLCJOb25jZSI6' + 'Ii9lWTRTTWR6TGFxbDlsRFc3bHUyZTZuSzJnVG9veVl0IiwiS' + 'WQiOiJra0ZuOGJEaEt4RUxtUjd0NkJxTU0vdHhMMFVqaEZjR1' + 'BORldUUExGdjVla1dWUjA4Uk1sbGJhc3c4VGdFbkhzM0laeVo' + '0V2lEOHhRUWdjU3ljOHY4eUt4dEhxejVEczJOYmh1ZDJhUFdt' + 'UTVMcVlNWitmZ2FNaTAxdE9DIn0=') + cav = bakery.decode_caveat(tp_key, encrypted_cav) + self.assertEquals(cav, bakery.ThirdPartyCaveatInfo( + condition='caveat condition', + first_party_public_key=fp_key.public_key, + third_party_key_pair=tp_key, + root_key=root_key, + caveat=encrypted_cav, + version=bakery.VERSION_1, + id=None, + namespace=bakery.legacy_namespace() + )) + + def test_decode_caveat_v2_from_go(self): + tp_key = bakery.PrivateKey(nacl.public.PrivateKey( + base64.b64decode( + 'TSpvLpQkRj+T3JXnsW2n43n5zP/0X4zn0RvDiWC3IJ0='))) + fp_key = bakery.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 = bakery.b64decode( + 'AvD-xlUf2MdGMgtu7OKRQnCP1OQJk6PKeFWRK26WIBA6DNwKGIHq9xGcHS9IZ' + 'Lh0cL6D9qpeKI0mXmCPfnwRQDuVYC8y5gVWd-oCGZaj5TGtk3byp2Vnw6ojmt' + 'sULDhY59YA_J_Y0ATkERO5T9ajoRWBxU2OXBoX6bImXA', + ) + cav = bakery.decode_caveat(tp_key, encrypted_cav) + self.assertEqual(cav, bakery.ThirdPartyCaveatInfo( + condition='third party condition', + first_party_public_key=fp_key.public_key, + third_party_key_pair=tp_key, + root_key=root_key, + caveat=encrypted_cav, + version=bakery.VERSION_2, + id=None, + namespace=bakery.legacy_namespace() + )) + + def test_decode_caveat_v3_from_go(self): + tp_key = bakery.PrivateKey( + nacl.public.PrivateKey(base64.b64decode( + 'TSpvLpQkRj+T3JXnsW2n43n5zP/0X4zn0RvDiWC3IJ0='))) + fp_key = bakery.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 = bakery.b64decode( + 'A_D-xlUf2MdGMgtu7OKRQnCP1OQJk6PKeFWRK26WIBA6DNwKGNLeFSkD2M-8A' + 'EYvmgVH95GWu7T7caKxKhhOQFcEKgnXKJvYXxz1zin4cZc4Q6C7gVqA-J4_j3' + '1LX4VKxymqG62UGPo78wOv0_fKjr3OI6PPJOYOQgBMclemlRF2', + ) + cav = bakery.decode_caveat(tp_key, encrypted_cav) + self.assertEquals(cav, bakery.ThirdPartyCaveatInfo( + condition='third party condition', + first_party_public_key=fp_key.public_key, + third_party_key_pair=tp_key, + root_key=root_key, + caveat=encrypted_cav, + version=bakery.VERSION_3, + id=None, + namespace=bakery.legacy_namespace() + )) + + def test_encode_decode_varint(self): + tests = [ + (12, [12]), + (127, [127]), + (128, [128, 1]), + (129, [129, 1]), + (1234567, [135, 173, 75]), + (12131231231312, [208, 218, 233, 173, 136, 225, 2]) + ] + for test in tests: + data = bytearray() + expected = bytearray() + bakery.encode_uvarint(test[0], data) + for v in test[1]: + expected.append(v) + self.assertEquals(data, expected) + val = codec.decode_uvarint(bytes(data)) + self.assertEquals(test[0], val[0]) + self.assertEquals(len(test[1]), val[1]) diff --git a/macaroonbakery/tests/test_discharge.py b/macaroonbakery/tests/test_discharge.py new file mode 100644 index 0000000..5360317 --- /dev/null +++ b/macaroonbakery/tests/test_discharge.py @@ -0,0 +1,517 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +import os +import unittest + +import macaroonbakery.bakery as bakery +import macaroonbakery.checkers as checkers +from macaroonbakery.tests import common +from pymacaroons import MACAROON_V1, Macaroon + + +class TestDischarge(unittest.TestCase): + 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(bakery.LATEST_VERSION, + common.ages, None, + [bakery.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'), [bakery.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 = bakery.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(bakery.LATEST_VERSION, + common.ages, + None, [bakery.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(cav, payload): + self.assertEqual(cav.location, 'bs-loc') + return bakery.discharge( + common.test_context, + cav.caveat_id_bytes, + payload, + bs.oven.key, + common.ThirdPartyStrcmpChecker('user==bob'), + bs.oven.locator, + ) + + d = bakery.discharge_all(ts_macaroon, get_discharge) + + ts.checker.auth([d]).allow(common.test_context, + [bakery.LOGIN_OP]) + + def test_discharge_with_version1_macaroon(self): + locator = bakery.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(bakery.VERSION_1, common.ages, + None, [bakery.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(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 bakery.discharge( + common.test_context, + cav.caveat_id_bytes, + payload, + bs.oven.key, + common.ThirdPartyStrcmpChecker('something'), + bs.oven.locator, + ) + + d = bakery.discharge_all(ts_macaroon, get_discharge) + + ts.checker.auth([d]).allow(common.test_context, + [bakery.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 = bakery.MemoryKeyStore() + b = bakery.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, + [bakery.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 = bakery.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(bakery.LATEST_VERSION, + common.ages, None, + [bakery.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, + bakery.LOGIN_OP + ) + self.fail('macaroon unmet should be raised') + except bakery.PermissionDenied: + 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 = bakery.ThirdPartyStore() + bs = common.new_bakery('bs-loc', locator) + ts = common.new_bakery('ts-loc', locator) + + # ts creates a macaroon. + ts_macaroon = ts.oven.macaroon(bakery.LATEST_VERSION, + common.ages, None, + [bakery.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(cav, payload): + self.assertEqual(cav.location, 'bs-loc') + return bakery.discharge( + common.test_context, + cav.caveat_id_bytes, + payload, + bs.oven.key, + common.ThirdPartyStrcmpChecker('user==bob'), + bs.oven.locator, + ) + + d = bakery.discharge_all(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(bakery.PermissionDenied) as exc: + ts.checker.auth([d]).allow(common.test_context, + bakery.LOGIN_OP) + self.assertEqual('verification failed: Signatures do not match', + exc.exception.args[0]) + + def test_need_declared(self): + locator = bakery.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( + bakery.LATEST_VERSION, common.ages, [ + checkers.need_declared_caveat( + checkers.Caveat(location='third', condition='something'), + ['foo', 'bar'] + ) + ], [bakery.LOGIN_OP]) + + # The client asks for a discharge macaroon for each third party caveat. + def get_discharge(cav, payload): + return bakery.discharge( + common.test_context, + cav.caveat_id_bytes, + payload, + third_party.oven.key, + common.ThirdPartyStrcmpChecker('something'), + third_party.oven.locator, + ) + + d = bakery.discharge_all(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, [bakery.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(cav, payload): + checker = common.ThirdPartyCheckerWithCaveats([ + checkers.declared_caveat('foo', 'a'), + checkers.declared_caveat('arble', 'b') + ]) + return bakery.discharge( + common.test_context, + cav.caveat_id_bytes, + payload, + third_party.oven.key, + checker, + third_party.oven.locator, + ) + + d = bakery.discharge_all(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, [bakery.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(cav, payload): + checker = common.ThirdPartyCheckerWithCaveats([ + checkers.declared_caveat('foo', 'a'), + checkers.declared_caveat('arble', 'b'), + ]) + + # Sneaky client adds a first party caveat. + m = bakery.discharge( + common.test_context, 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 = bakery.discharge_all(m, get_discharge) + + declared = checkers.infer_declared(d, first_party.checker.namespace()) + self.assertEqual(declared, { + 'bar': '', + 'arble': 'b', + }) + + with self.assertRaises(bakery.PermissionDenied) as exc: + first_party.checker.auth([d]).allow(common.test_context, + bakery.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 = bakery.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( + bakery.LATEST_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']), + ], [bakery.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(cav, payload): + return bakery.discharge( + common.test_context, + cav.caveat_id_bytes, + payload, + third_party.oven.key, + common.ThirdPartyCaveatCheckerEmpty(), + third_party.oven.locator, + ) + + d = bakery.discharge_all(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, [bakery.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(bakery.ThirdPartyCaveatChecker): + def check_third_party_caveat(self, ctx, cav_info): + if cav_info.condition == 'x': + return [checkers.declared_caveat('foo', 'fooval1')] + if cav_info.condition == 'y': + return [ + checkers.declared_caveat('foo', 'fooval2'), + checkers.declared_caveat('baz', 'bazval') + ] + raise bakery.ThirdPartyCaveatCheckFailed('not matched') + + def get_discharge(cav, payload): + return bakery.discharge( + common.test_context, + cav.caveat_id_bytes, + payload, + third_party.oven.key, + ThirdPartyCaveatCheckerF(), + third_party.oven.locator, + ) + + d = bakery.discharge_all(m, get_discharge) + + declared = checkers.infer_declared(d, first_party.checker.namespace()) + self.assertEqual(declared, { + 'bar': '', + 'baz': 'bazval', + }) + with self.assertRaises(bakery.PermissionDenied) as exc: + first_party.checker.auth([d]).allow(common.test_context, + bakery.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 = bakery.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(bakery.LATEST_VERSION, + common.ages, [ + checkers.Caveat(location='third', + condition='true')], + [bakery.LOGIN_OP]) + + # Acquire the discharge macaroon, but don't bind it to the original. + class M: + unbound = None + + def get_discharge(cav, payload): + m = bakery.discharge( + common.test_context, + cav.caveat_id_bytes, + payload, + third_party.oven.key, + common.ThirdPartyStrcmpChecker('true'), + third_party.oven.locator, + ) + M.unbound = m.macaroon.copy() + return m + + bakery.discharge_all(m, get_discharge) + self.assertIsNotNone(M.unbound) + + # Make sure it cannot be used as a normal macaroon in the third party. + with self.assertRaises(bakery.PermissionDenied) as exc: + third_party.checker.auth([[M.unbound]]).allow( + common.test_context, [bakery.LOGIN_OP]) + self.assertEqual('no operations found in macaroon', + exc.exception.args[0]) + + def test_third_party_discharge_macaroon_ids_are_small(self): + locator = bakery.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(bakery.LATEST_VERSION, + common.ages, + None, [bakery.LOGIN_OP]) + ts_macaroon.add_caveat(checkers.Caveat(condition='something', + location='as1-loc'), + ts.oven.key, ts.oven.locator) + + class ThirdPartyCaveatCheckerF(bakery.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 bakery.ThirdPartyCaveatCheckFailed( + 'unknown location {}'.format(self._loc)) + + def get_discharge(cav, payload): + oven = bakeries[cav.location].oven + return bakery.discharge( + common.test_context, + cav.caveat_id_bytes, + payload, + oven.key, + ThirdPartyCaveatCheckerF(cav.location), + oven.locator, + ) + + d = bakery.discharge_all(ts_macaroon, get_discharge) + ts.checker.auth([d]).allow(common.test_context, + [bakery.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)) + + def test_third_party_discharge_macaroon_wrong_root_key_and_third_party_caveat(self): + + root_keys = bakery.MemoryKeyStore() + ts = bakery.Bakery( + key=bakery.generate_key(), + checker=common.test_checker(), + root_key_store=root_keys, + identity_client=common.OneIdentity(), + ) + locator = bakery.ThirdPartyStore() + bs = common.new_bakery('bs-loc', locator) + + # ts creates a macaroon with a third party caveat addressed to bs. + ts_macaroon = ts.oven.macaroon(bakery.LATEST_VERSION, + common.ages, + None, [bakery.LOGIN_OP]) + ts_macaroon.add_caveat( + checkers.Caveat(location='bs-loc', condition='true'), + ts.oven.key, locator, + ) + + def get_discharge(cav, payload): + return bakery.discharge( + common.test_context, + cav.caveat_id_bytes, + payload, + bs.oven.key, + common.ThirdPartyStrcmpChecker('true'), + bs.oven.locator, + ) + + d = bakery.discharge_all(ts_macaroon, get_discharge) + + # The authorization should succeed at first. + ts.checker.auth([d]).allow(common.test_context, [bakery.LOGIN_OP]) + # Corrupt the root key and try again. + # We should get a DischargeRequiredError because the verification has failed. + root_keys._key = os.urandom(24) + with self.assertRaises(bakery.PermissionDenied) as err: + ts.checker.auth([d]).allow(common.test_context, [bakery.LOGIN_OP]) + self.assertEqual(str(err.exception), 'verification failed: Decryption failed. Ciphertext failed verification') diff --git a/macaroonbakery/tests/test_discharge_all.py b/macaroonbakery/tests/test_discharge_all.py new file mode 100644 index 0000000..cab8a07 --- /dev/null +++ b/macaroonbakery/tests/test_discharge_all.py @@ -0,0 +1,164 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +import unittest + +import macaroonbakery.bakery as bakery +import macaroonbakery.checkers as checkers +from macaroonbakery.tests import common +from pymacaroons.verifier import Verifier + + +def always_ok(predicate): + return True + + +class TestDischargeAll(unittest.TestCase): + def test_discharge_all_no_discharges(self): + root_key = b'root key' + m = bakery.Macaroon( + root_key=root_key, id=b'id0', location='loc0', + version=bakery.LATEST_VERSION, + namespace=common.test_checker().namespace()) + ms = bakery.discharge_all(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 = bakery.Macaroon( + root_key=root_key, id=b'id0', location='loc0', + version=bakery.LATEST_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 = bakery.Macaroon( + root_key='root key {}'.format( + cav.caveat_id.decode('utf-8')).encode('utf-8'), + id=cav.caveat_id, location='', + version=bakery.LATEST_VERSION) + + add_caveats(m) + return m + + ms = bakery.discharge_all(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 = bakery.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 = bakery.Macaroon( + root_key=root_key, id=b'id0', location='ts-loc', + version=bakery.LATEST_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(bakery.ThirdPartyCaveatChecker): + def check_third_party_caveat(self, ctx, info): + return checker(ctx, info) + + def get_discharge(cav, payload): + return bakery.discharge( + common.test_context, cav.caveat_id, payload, + bakeries[cav.location].oven.key, + ThirdPartyCaveatCheckerF(), locator) + + ms = bakery.discharge_all(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 = bakery.generate_key() + m = oc.oven.macaroon(bakery.LATEST_VERSION, common.ages, + [ + bakery.local_third_party_caveat( + client_key.public_key, + bakery.LATEST_VERSION) + ], [bakery.LOGIN_OP]) + ms = bakery.discharge_all(m, no_discharge(self), client_key) + oc.checker.auth([ms]).allow(common.test_context, + [bakery.LOGIN_OP]) + + def test_discharge_all_local_discharge_version1(self): + oc = common.new_bakery('ts', None) + client_key = bakery.generate_key() + m = oc.oven.macaroon(bakery.VERSION_1, common.ages, [ + bakery.local_third_party_caveat( + client_key.public_key, bakery.VERSION_1) + ], [bakery.LOGIN_OP]) + ms = bakery.discharge_all(m, no_discharge(self), client_key) + oc.checker.auth([ms]).allow(common.test_context, + [bakery.LOGIN_OP]) + + +def no_discharge(test): + def get_discharge(cav, payload): + test.fail("get_discharge called unexpectedly") + + return get_discharge diff --git a/macaroonbakery/tests/test_httpbakery.py b/macaroonbakery/tests/test_httpbakery.py new file mode 100644 index 0000000..c372f13 --- /dev/null +++ b/macaroonbakery/tests/test_httpbakery.py @@ -0,0 +1,44 @@ +from unittest import TestCase + +import macaroonbakery.httpbakery as httpbakery +import macaroonbakery.bakery as bakery + + +class TestWebBrowserInteractionInfo(TestCase): + + def test_from_dict(self): + info_dict = { + 'VisitURL': 'https://example.com/visit', + 'WaitTokenURL': 'https://example.com/wait'} + interaction_info = httpbakery.WebBrowserInteractionInfo.from_dict(info_dict) + self.assertEqual( + interaction_info.visit_url, 'https://example.com/visit') + self.assertEqual( + interaction_info.wait_token_url, 'https://example.com/wait') + + +class TestError(TestCase): + + def test_from_dict_upper_case_fields(self): + err = httpbakery.Error.from_dict({ + 'Message': 'm', + 'Code': 'c', + }) + self.assertEqual(err, httpbakery.Error( + code='c', + message='m', + info=None, + version=bakery.LATEST_VERSION, + )) + + def test_from_dict_lower_case_fields(self): + err = httpbakery.Error.from_dict({ + 'message': 'm', + 'code': 'c', + }) + self.assertEqual(err, httpbakery.Error( + code='c', + message='m', + info=None, + version=bakery.LATEST_VERSION, + )) diff --git a/macaroonbakery/tests/test_keyring.py b/macaroonbakery/tests/test_keyring.py new file mode 100644 index 0000000..3503145 --- /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 + +import macaroonbakery.bakery as bakery +import macaroonbakery.httpbakery as httpbakery + +from httmock import HTTMock, urlmatch + + +class TestKeyRing(unittest.TestCase): + + def test_cache_fetch(self): + key = bakery.generate_key() + + @urlmatch(path='.*/discharge/info') + def discharge_info(url, request): + return { + 'status_code': 200, + 'content': { + 'Version': bakery.LATEST_VERSION, + 'PublicKey': str(key.public_key), + } + } + + expectInfo = bakery.ThirdPartyInfo( + public_key=key.public_key, + version=bakery.LATEST_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 = bakery.generate_key() + + @urlmatch(path='.*/discharge/info') + def discharge_info(url, request): + return { + 'status_code': 200, + 'content': { + 'Version': bakery.LATEST_VERSION, + 'PublicKey': str(key.public_key), + } + } + + expectInfo = bakery.ThirdPartyInfo( + public_key=key.public_key, + version=bakery.LATEST_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 = bakery.generate_key() + + @urlmatch(path='.*/discharge/info') + def discharge_info(url, request): + return { + 'status_code': 200, + 'content': { + 'PublicKey': str(key.public_key), + } + } + + expectInfo = bakery.ThirdPartyInfo( + public_key=key.public_key, + version=bakery.VERSION_1 + ) + 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(bakery.ThirdPartyInfoNotFound): + kr.third_party_info('http://0.1.2.3/') + + def test_fallback(self): + key = bakery.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': str(key.public_key), + } + } + + expectInfo = bakery.ThirdPartyInfo( + public_key=key.public_key, + version=bakery.VERSION_1 + ) + 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 new file mode 100644 index 0000000..bcbbf80 --- /dev/null +++ b/macaroonbakery/tests/test_macaroon.py @@ -0,0 +1,201 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +import json +from unittest import TestCase + +import macaroonbakery.bakery as bakery +import macaroonbakery.checkers as checkers +import pymacaroons +import six +from macaroonbakery.tests import common +from pymacaroons import serializers + + +class TestMacaroon(TestCase): + def test_new_macaroon(self): + m = bakery.Macaroon( + b'rootkey', + b'some id', + 'here', + bakery.LATEST_VERSION) + self.assertIsNotNone(m) + self.assertEquals(m._macaroon.identifier, b'some id') + self.assertEquals(m._macaroon.location, 'here') + self.assertEquals(m.version, bakery.LATEST_VERSION) + + def test_add_first_party_caveat(self): + m = bakery.Macaroon('rootkey', 'some id', 'here', + bakery.LATEST_VERSION) + m.add_caveat(checkers.Caveat('test_condition')) + caveats = m.first_party_caveats() + self.assertEquals(len(caveats), 1) + self.assertEquals(caveats[0].caveat_id, b'test_condition') + + def test_add_third_party_caveat(self): + locator = bakery.ThirdPartyStore() + bs = common.new_bakery('bs-loc', locator) + + lbv = six.int2byte(bakery.LATEST_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 = bakery.Macaroon( + root_key=b'root key', id=b'id', + location='location', + version=bakery.LATEST_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 = bakery.ThirdPartyStore() + bs = common.new_bakery('bs-loc', locator) + ns = checkers.Namespace({ + 'testns': 'x', + 'otherns': 'y', + }) + m = bakery.Macaroon( + root_key=b'root key', id=b'id', + location='location', + version=bakery.LATEST_VERSION, + namespace=ns) + m.add_caveat(checkers.Caveat(location='bs-loc', condition='something'), + bs.oven.key, locator) + data = m.serialize_json() + m1 = bakery.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=bakery.MacaroonJSONEncoder) + m1 = json.loads(data, cls=bakery.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(bakery.VERSION_1) + + def test_json_version2(self): + self._test_json_with_version(bakery.VERSION_2) + + def _test_json_with_version(self, version): + locator = bakery.ThirdPartyStore() + bs = common.new_bakery('bs-loc', locator) + + ns = checkers.Namespace({ + 'testns': 'x', + }) + + m = bakery.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=bakery.MacaroonJSONEncoder) + m1 = json.loads(data, cls=bakery.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, + bakery.macaroon_version(version)) + self.assertEqual(len(m1.macaroon.caveats), 1) + + # Namespace information has been thrown away. + self.assertEqual(m1.namespace, bakery.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': bakery.LATEST_VERSION + 1 + }), cls=bakery.MacaroonJSONDecoder) + self.assertEqual('unknown 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': bakery.LATEST_VERSION + }), cls=bakery.MacaroonJSONDecoder) + self.assertEqual('underlying macaroon has inconsistent version; ' + 'got 1 want 2', exc.exception.args[0]) + + def test_clone(self): + locator = bakery.ThirdPartyStore() + bs = common.new_bakery("bs-loc", locator) + ns = checkers.Namespace({ + "testns": "x", + }) + m = bakery.Macaroon( + root_key=b'root key', id=b'id', + location='location', + version=bakery.LATEST_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 = bakery.Macaroon( + root_key=b'rootkey', id=b'some id', location='here', + version=bakery.LATEST_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 = bakery.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 new file mode 100644 index 0000000..8a821e5 --- /dev/null +++ b/macaroonbakery/tests/test_namespace.py @@ -0,0 +1,59 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +from unittest import TestCase + +import macaroonbakery.checkers as checkers + + +class TestNamespace(TestCase): + def test_serialize(self): + tests = [ + ('empty namespace', None, b''), + ('standard namespace', {'std': ''}, b'std:'), + ('several elements', { + 'std': '', + 'http://blah.blah': 'blah', + 'one': 'two', + 'foo.com/x.v0.1': 'z', + }, b'foo.com/x.v0.1:z http://blah.blah:blah one:two std:'), + ('sort by URI not by field', { + 'a': 'one', + 'a1': 'two', + }, b'a:one a1:two') + ] + for test in tests: + ns = 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 = checkers.deserialize_namespace(data) + self.assertEquals(ns1, ns) + + # TODO(rogpeppe) add resolve tests + + def test_register(self): + ns = checkers.Namespace(None) + ns.register('testns', 't') + prefix = ns.resolve('testns') + self.assertEquals(prefix, 't') + + ns.register('other', 'o') + prefix = ns.resolve('other') + self.assertEquals(prefix, 'o') + + # If we re-register the same URL, it does nothing. + ns.register('other', 'p') + prefix = ns.resolve('other') + self.assertEquals(prefix, 'o') + + def test_register_bad_uri(self): + ns = checkers.Namespace(None) + with self.assertRaises(KeyError): + ns.register('', 'x') + + def test_register_bad_prefix(self): + 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..3c29767 --- /dev/null +++ b/macaroonbakery/tests/test_oven.py @@ -0,0 +1,124 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +import copy +from datetime import datetime, timedelta +from unittest import TestCase + +import macaroonbakery.bakery as bakery + +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', [bakery.Op('a', 'a')], + [bakery.Op('a', 'a')]), + ('all in order', + [bakery.Op('a', 'a'), bakery.Op('a', 'b'), + bakery.Op('c', 'c')], + [bakery.Op('a', 'a'), bakery.Op('a', 'b'), + bakery.Op('c', 'c')]), + ('out of order', + [bakery.Op('c', 'c'), bakery.Op('a', 'b'), + bakery.Op('a', 'a')], + [bakery.Op('a', 'a'), bakery.Op('a', 'b'), + bakery.Op('c', 'c')]), + ('with duplicates', + [bakery.Op('c', 'c'), bakery.Op('a', 'b'), + bakery.Op('a', 'a'), bakery.Op('c', 'a'), + bakery.Op('c', 'b'), bakery.Op('c', 'c'), + bakery.Op('a', 'a')], + [bakery.Op('a', 'a'), bakery.Op('a', 'b'), + bakery.Op('c', 'a'), bakery.Op('c', 'b'), + bakery.Op('c', 'c')]), + ('make sure we\'ve got the fields right', + [bakery.Op(entity='read', action='two'), + bakery.Op(entity='read', action='one'), + bakery.Op(entity='write', action='one')], + [bakery.Op(entity='read', action='one'), + bakery.Op(entity='read', action='two'), + bakery.Op(entity='write', action='one')]) + ) + for about, ops, expected in canonical_ops_tests: + new_ops = copy.copy(ops) + canonical_ops = bakery.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 = bakery.Oven( + ops_store=bakery.MemoryOpsStore()) + ops = [bakery.Op('one', 'read'), + bakery.Op('one', 'write'), + bakery.Op('two', 'read')] + m = test_oven.macaroon(bakery.LATEST_VERSION, AGES, + None, ops) + got_ops, conds = test_oven.macaroon_ops([m.macaroon]) + self.assertEquals(len(conds), 1) # time-before caveat. + self.assertEquals(bakery.canonical_ops(got_ops), ops) + + def test_multiple_ops_in_id(self): + test_oven = bakery.Oven() + ops = [bakery.Op('one', 'read'), + bakery.Op('one', 'write'), + bakery.Op('two', 'read')] + m = test_oven.macaroon(bakery.LATEST_VERSION, AGES, + None, ops) + got_ops, conds = test_oven.macaroon_ops([m.macaroon]) + self.assertEquals(len(conds), 1) # time-before caveat. + self.assertEquals(bakery.canonical_ops(got_ops), ops) + + def test_multiple_ops_in_id_with_version1(self): + test_oven = bakery.Oven() + ops = [bakery.Op('one', 'read'), + bakery.Op('one', 'write'), + bakery.Op('two', 'read')] + m = test_oven.macaroon(bakery.VERSION_1, AGES, None, ops) + got_ops, conds = test_oven.macaroon_ops([m.macaroon]) + self.assertEquals(len(conds), 1) # time-before caveat. + self.assertEquals(bakery.canonical_ops(got_ops), ops) + + def test_huge_number_of_ops_gives_small_macaroon(self): + test_oven = bakery.Oven( + ops_store=bakery.MemoryOpsStore()) + ops = [] + for i in range(30000): + ops.append(bakery.Op(entity='entity' + str(i), + action='action' + str(i))) + + m = test_oven.macaroon(bakery.LATEST_VERSION, AGES, + None, ops) + got_ops, conds = test_oven.macaroon_ops([m.macaroon]) + self.assertEquals(len(conds), 1) # time-before caveat. + self.assertEquals(bakery.canonical_ops(got_ops), + bakery.canonical_ops(ops)) + + data = m.serialize_json() + self.assertLess(len(data), 300) + + def test_ops_stored_only_once(self): + st = bakery.MemoryOpsStore() + test_oven = bakery.Oven(ops_store=st) + + ops = [bakery.Op('one', 'read'), + bakery.Op('one', 'write'), + bakery.Op('two', 'read')] + + m = test_oven.macaroon(bakery.LATEST_VERSION, AGES, + None, ops) + got_ops, conds = test_oven.macaroon_ops([m.macaroon]) + self.assertEquals(bakery.canonical_ops(got_ops), + bakery.canonical_ops(ops)) + + # Make another macaroon containing the same ops in a different order. + ops = [bakery.Op('one', 'write'), + bakery.Op('one', 'read'), + bakery.Op('one', 'read'), + bakery.Op('two', 'read')] + test_oven.macaroon(bakery.LATEST_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..8a54f59 --- /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.bakery as bakery + + +class TestOven(TestCase): + def test_mem_store(self): + st = bakery.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/tests/test_time.py b/macaroonbakery/tests/test_time.py new file mode 100644 index 0000000..2685e56 --- /dev/null +++ b/macaroonbakery/tests/test_time.py @@ -0,0 +1,136 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +from collections import namedtuple +from datetime import timedelta +from unittest import TestCase + +import macaroonbakery.checkers as checkers +import pymacaroons +import pyrfc3339 +from pymacaroons import Macaroon + +t1 = pyrfc3339.parse('2017-10-26T16:19:47.441402074Z', produce_naive=True) +t2 = t1 + timedelta(hours=1) +t3 = t2 + timedelta(hours=1) + + +def fpcaveat(s): + return pymacaroons.Caveat(caveat_id=s.encode('utf-8')) + + +class TestExpireTime(TestCase): + def test_expire_time(self): + ExpireTest = namedtuple('ExpireTest', 'about caveats expectTime') + tests = [ + ExpireTest( + about='no caveats', + caveats=[], + expectTime=None, + ), + ExpireTest( + about='single time-before caveat', + caveats=[ + fpcaveat(checkers.time_before_caveat(t1).condition), + ], + expectTime=t1, + ), + ExpireTest( + about='multiple time-before caveat', + caveats=[ + fpcaveat(checkers.time_before_caveat(t2).condition), + fpcaveat(checkers.time_before_caveat(t1).condition), + ], + expectTime=t1, + ), + ExpireTest( + about='mixed caveats', + caveats=[ + fpcaveat(checkers.time_before_caveat(t1).condition), + fpcaveat('allow bar'), + fpcaveat(checkers.time_before_caveat(t2).condition), + fpcaveat('deny foo'), + ], + expectTime=t1, + ), + ExpireTest( + about='mixed caveats', + caveats=[ + fpcaveat(checkers.COND_TIME_BEFORE + ' tomorrow'), + ], + expectTime=None, + ), + ] + for test in tests: + print('test ', test.about) + t = checkers.expiry_time(checkers.Namespace(), test.caveats) + self.assertEqual(t, test.expectTime) + + def test_macaroons_expire_time(self): + ExpireTest = namedtuple('ExpireTest', 'about macaroons expectTime') + tests = [ + ExpireTest( + about='no macaroons', + macaroons=[newMacaroon()], + expectTime=None, + ), + ExpireTest( + about='single macaroon without caveats', + macaroons=[newMacaroon()], + expectTime=None, + ), + ExpireTest( + about='multiple macaroon without caveats', + macaroons=[newMacaroon()], + expectTime=None, + ), + ExpireTest( + about='single macaroon with time-before caveat', + macaroons=[ + newMacaroon([checkers.time_before_caveat(t1).condition]), + ], + expectTime=t1, + ), + ExpireTest( + about='single macaroon with multiple time-before caveats', + macaroons=[ + newMacaroon([ + checkers.time_before_caveat(t2).condition, + checkers.time_before_caveat(t1).condition, + ]), + ], + expectTime=t1, + ), + ExpireTest( + about='multiple macaroons with multiple time-before caveats', + macaroons=[ + newMacaroon([ + checkers.time_before_caveat(t3).condition, + checkers.time_before_caveat(t1).condition, + ]), + newMacaroon([ + checkers.time_before_caveat(t3).condition, + checkers.time_before_caveat(t1).condition, + ]), + ], + expectTime=t1, + ), + ] + for test in tests: + print('test ', test.about) + t = checkers.macaroons_expiry_time(checkers.Namespace(), + test.macaroons) + self.assertEqual(t, test.expectTime) + + def test_macaroons_expire_time_skips_third_party(self): + m1 = newMacaroon([checkers.time_before_caveat(t1).condition]) + m2 = newMacaroon() + m2.add_third_party_caveat('https://example.com', 'a-key', '123') + t = checkers.macaroons_expiry_time(checkers.Namespace(), [m1, m2]) + self.assertEqual(t1, t) + + +def newMacaroon(conds=[]): + m = Macaroon(key='key', version=2) + for cond in conds: + m.add_first_party_caveat(cond) + return m diff --git a/macaroonbakery/tests/test_utils.py b/macaroonbakery/tests/test_utils.py new file mode 100644 index 0000000..4ed3e81 --- /dev/null +++ b/macaroonbakery/tests/test_utils.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- + +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. + +import json +from datetime import datetime +from unittest import TestCase + +import macaroonbakery.bakery as bakery +import pymacaroons +from macaroonbakery._utils import cookie +from pymacaroons.serializers import json_serializer + + +class CookieTest(TestCase): + + def test_cookie_expires_naive(self): + timestamp = datetime.utcnow() + c = cookie('http://example.com', 'test', 'value', expires=timestamp) + self.assertEqual( + c.expires, int((timestamp - datetime(1970, 1, 1)).total_seconds())) + + def test_cookie_expires_with_timezone(self): + from datetime import tzinfo + timestamp = datetime.utcnow().replace(tzinfo=tzinfo()) + self.assertRaises( + ValueError, cookie, 'http://example.com', 'test', 'value', + expires=timestamp) + + def test_cookie_with_hostname_not_fqdn(self): + c = cookie('http://myhost', 'test', 'value') + self.assertEqual(c.domain, 'myhost.local') + + def test_cookie_with_hostname_ipv4(self): + c = cookie('http://1.2.3.4', 'test', 'value') + self.assertEqual(c.domain, '1.2.3.4') + + def test_cookie_with_hostname_ipv6(self): + c = cookie('http://[dead::beef]', 'test', 'value') + self.assertEqual(c.domain, 'dead::beef') + + def test_cookie_with_hostname_like_ipv4(self): + c = cookie('http://1.2.3.4.com', 'test', 'value') + self.assertEqual(c.domain, '1.2.3.4.com') + + def test_cookie_with_hostname_not_ascii(self): + c = cookie('http://κουλουράκι', 'test', 'value') + self.assertEqual(c.domain, 'κουλουράκι.local') + + +class TestB64Decode(TestCase): + def test_decode(self): + test_cases = [{ + 'about': 'empty string', + 'input': '', + 'expect': '', + }, { + 'about': 'standard encoding, padded', + 'input': 'Z29+IQ==', + 'expect': 'go~!', + }, { + 'about': 'URL encoding, padded', + 'input': 'Z29-IQ==', + 'expect': 'go~!', + }, { + 'about': 'standard encoding, not padded', + 'input': 'Z29+IQ', + 'expect': 'go~!', + }, { + 'about': 'URL encoding, not padded', + 'input': 'Z29-IQ', + 'expect': 'go~!', + }, { + 'about': 'standard encoding, not enough much padding', + 'input': 'Z29+IQ=', + 'expect_error': 'illegal base64 data at input byte 8', + }] + for test in test_cases: + if test.get('expect_error'): + with self.assertRaises(ValueError, msg=test['about']) as e: + bakery.b64decode(test['input']) + self.assertEqual(str(e.exception), 'Incorrect padding') + else: + self.assertEqual(bakery.b64decode(test['input']), test['expect'].encode('utf-8'), msg=test['about']) + + +class MacaroonToDictTest(TestCase): + def test_macaroon_to_dict(self): + m = pymacaroons.Macaroon( + key=b'rootkey', identifier=b'some id', location='here', version=2) + as_dict = bakery.macaroon_to_dict(m) + data = json.dumps(as_dict) + m1 = pymacaroons.Macaroon.deserialize(data, json_serializer.JsonSerializer()) + self.assertEqual(m1.signature, m.signature) + pymacaroons.Verifier().verify(m1, b'rootkey') diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..1e3eb36 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,7 @@ +[wheel] +universal = 1 + +[egg_info] +tag_build = +tag_date = 0 + diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..4cf63f0 --- /dev/null +++ b/setup.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python + +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +from setuptools import ( + find_packages, + setup, +) + + +PROJECT_NAME = 'macaroonbakery' + +# version 1.3.1 +VERSION = (1, 3, 1) + + +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.18.1,<3.0', + 'PyNaCl>=1.1.2,<2.0', + 'pymacaroons>=0.12.0,<1.0', + 'six>=1.11.0,<2.0', + 'protobuf>=3.0.0,<4.0', + 'pyRFC3339>=1.0,<2.0', + 'ipaddress;python_version<"3"', + 'cryptography==1.3.2;python_full_version<"2.7.9"', + 'ndg_httpsclient==0.3.3;python_full_version<"2.7.9"', + 'pyasn1==0.1.9;python_full_version<"2.7.9"', + 'pyOpenSSL==16.0.0;python_full_version<"2.7.9"', +] + +test_requirements = [ + 'tox', + 'fixtures', + 'httmock==1.2.5', +] + +setup( + name=PROJECT_NAME, + version=get_version(), + description='A Python library port for bakery, higher level operation ' + 'to work with macaroons', + long_description=readme, + author="Juju UI Team", + author_email='juju-gui@lists.ubuntu.com', + url='https://github.com/go-macaroon-bakery/py-macaroon-bakery', + packages=find_packages(), + include_package_data=True, + install_requires=requirements, + license="LGPL3", + zip_safe=False, + keywords='macaroon cookie', + classifiers=[ + 'Development Status :: 2 - Pre-Alpha', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)', + 'Natural Language :: English', + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.5', + ], + test_suite='tests', + tests_require=test_requirements, +) -- cgit v1.2.3 From 42843a27d911c33d29deddb860ad8fe1bb6d5218 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jelmer=20Vernoo=C4=B3?= Date: Sun, 16 Oct 2022 18:08:31 +0100 Subject: Import py-macaroon-bakery_1.3.1-4.debian.tar.xz [dgit import tarball py-macaroon-bakery 1.3.1-4 py-macaroon-bakery_1.3.1-4.debian.tar.xz] --- changelog | 150 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ control | 44 ++++++++++++++++ copyright | 10 ++++ rules | 7 +++ source/format | 1 + upstream/metadata | 3 ++ watch | 3 ++ 7 files changed, 218 insertions(+) create mode 100644 changelog create mode 100644 control create mode 100644 copyright create mode 100755 rules create mode 100644 source/format create mode 100644 upstream/metadata create mode 100644 watch diff --git a/changelog b/changelog new file mode 100644 index 0000000..ccab811 --- /dev/null +++ b/changelog @@ -0,0 +1,150 @@ +py-macaroon-bakery (1.3.1-4) unstable; urgency=medium + + [ Debian Janitor ] + * Set upstream metadata fields: Repository-Browse. + + -- Jelmer Vernooij Sun, 16 Oct 2022 18:08:31 +0100 + +py-macaroon-bakery (1.3.1-3) unstable; urgency=medium + + * Switch from nose to pytest. + + -- Colin Watson Mon, 22 Aug 2022 11:51:37 +0100 + +py-macaroon-bakery (1.3.1-2) unstable; urgency=medium + + [ Debian Janitor ] + * Bump debhelper from old 9 to 12. + * Set upstream metadata fields: Bug-Database, Bug-Submit. + + [ Ondřej Nový ] + * d/control: Update Maintainer field with new Debian Python Team + contact address. + * d/control: Update Vcs-* fields with new Debian Python Team Salsa + layout. + + [ Debian Janitor ] + * Bump debhelper from old 12 to 13. + * Remove constraints unnecessary since buster: + + Build-Depends: Drop versioned constraint on python3-nacl, + python3-protobuf, python3-pymacaroons, python3-requests, python3-rfc3339 + and python3-six. + + -- Colin Watson Sun, 26 Dec 2021 13:20:06 +0000 + +py-macaroon-bakery (1.3.1-1) unstable; urgency=medium + + * New upstream release. + + -- Colin Watson Fri, 14 Feb 2020 11:22:12 +0000 + +py-macaroon-bakery (1.3.0-1) unstable; urgency=medium + + * New upstream release. + + -- Colin Watson Wed, 29 Jan 2020 17:25:18 +0000 + +py-macaroon-bakery (1.2.3-3) unstable; urgency=medium + + * Cherry-pick from upstream: + - Avoid using deprecated platform.dist() in setup.py. + + -- Colin Watson Thu, 26 Dec 2019 22:46:22 +0000 + +py-macaroon-bakery (1.2.3-2) unstable; urgency=medium + + [ Ondřej Nový ] + * Use debhelper-compat instead of debian/compat. + * d/control: Remove ancient X-Python3-Version field. + + -- Colin Watson Thu, 26 Dec 2019 02:13:31 +0000 + +py-macaroon-bakery (1.2.3-1) unstable; urgency=medium + + [ Ondřej Nový ] + * d/watch: Use https protocol. + + [ Colin Watson ] + * New upstream release. + + -- Colin Watson Thu, 09 May 2019 09:54:14 +0100 + +py-macaroon-bakery (1.2.1-1) unstable; urgency=medium + + * New upstream release. + + -- Colin Watson Mon, 15 Oct 2018 10:59:57 +0100 + +py-macaroon-bakery (1.2.0-1) unstable; urgency=medium + + * debian/watch: Switch to PyPI, which upstream updates more reliably than + GitHub releases. + * New upstream release. + + -- Colin Watson Fri, 12 Oct 2018 15:29:00 +0100 + +py-macaroon-bakery (1.1.4-1) unstable; urgency=medium + + [ Ondřej Nový ] + * Convert git repository from git-dpm to gbp layout + + [ Colin Watson ] + * New upstream release. + + -- Colin Watson Mon, 20 Aug 2018 17:54:44 +0100 + +py-macaroon-bakery (1.1.3-1) unstable; urgency=medium + + * New upstream release. + + -- Colin Watson Wed, 28 Feb 2018 17:09:45 +0000 + +py-macaroon-bakery (1.1.2-1) unstable; urgency=medium + + * New upstream release. + + -- Colin Watson Tue, 20 Feb 2018 23:47:51 +0000 + +py-macaroon-bakery (1.1.0-2) unstable; urgency=medium + + * Move VCS to salsa.debian.org. + * Improve mock setup for 407-then-unknown test. + + -- Colin Watson Fri, 09 Feb 2018 22:05:55 +0000 + +py-macaroon-bakery (1.1.0-1) unstable; urgency=medium + + * New upstream release. + + -- Colin Watson Mon, 05 Feb 2018 16:04:22 +0000 + +py-macaroon-bakery (0.0.6-1) unstable; urgency=medium + + * New upstream release. + + -- Colin Watson Sat, 11 Nov 2017 12:55:42 +0000 + +py-macaroon-bakery (0.0.5-1) unstable; urgency=medium + + * New upstream release. + * Apply https://github.com/go-macaroon-bakery/py-macaroon-bakery/pull/28 + to isolate client tests from any configured HTTP proxy. + * Set Rules-Requires-Root: no. + + -- Colin Watson Mon, 06 Nov 2017 10:42:31 +0000 + +py-macaroon-bakery (0.0.4-1) unstable; urgency=medium + + * New upstream release. + * Cherry-pick + https://github.com/go-macaroon-bakery/py-macaroon-bakery/pull/26 to + lower protobuf and requests dependencies to versions satisfiable in + unstable. + + -- Colin Watson Fri, 03 Nov 2017 16:11:44 +0000 + +py-macaroon-bakery (0.0.3-1) unstable; urgency=medium + + * Initial release (closes: #866779). + + -- Colin Watson Thu, 02 Nov 2017 14:35:45 +0000 diff --git a/control b/control new file mode 100644 index 0000000..466239c --- /dev/null +++ b/control @@ -0,0 +1,44 @@ +Source: py-macaroon-bakery +Section: python +Priority: optional +Maintainer: Debian Python Team +Uploaders: Colin Watson +Build-Depends: debhelper-compat (= 13), + dh-python, + python3-all, + python3-fixtures, + python3-httmock, + python3-mock, + python3-nacl, + python3-protobuf, + python3-pymacaroons, + python3-pytest, + python3-requests, + python3-rfc3339, + python3-setuptools, + python3-six, +Standards-Version: 4.1.1 +Vcs-Git: https://salsa.debian.org/python-team/packages/py-macaroon-bakery.git +Vcs-Browser: https://salsa.debian.org/python-team/packages/py-macaroon-bakery +Homepage: https://github.com/go-macaroon-bakery/py-macaroon-bakery +Rules-Requires-Root: no + +Package: python3-macaroonbakery +Architecture: all +Multi-Arch: foreign +Depends: ${misc:Depends}, + ${python3:Depends} +Description: Higher-level macaroon operations for Python 3 + Macaroons, like cookies, are a form of bearer credential. Unlike opaque + tokens, macaroons embed caveats that define specific authorization + requirements for the target service, the service that issued the root + macaroon and which is capable of verifying the integrity of macaroons it + receives. + . + Macaroons allow for delegation and attenuation of authorization. They are + simple and fast to verify, and decouple authorization policy from the + enforcement of that policy. + . + The macaroonbakery library builds on pymacaroons to allow working with + macaroons at a higher level, such as by automatically gathering discharge + macaroons for third-party caveats from their corresponding services. diff --git a/copyright b/copyright new file mode 100644 index 0000000..ed4a695 --- /dev/null +++ b/copyright @@ -0,0 +1,10 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: py-macaroon-bakery +Upstream-Contact: Juju UI Team +Source: https://github.com/go-macaroon-bakery/py-macaroon-bakery + +Files: * +Copyright: 2017 Canonical Ltd. +License: LGPL-3 + On Debian and Debian-based systems, a copy of the GNU Lesser General Public + License version 3 is available in /usr/share/common-licenses/LGPL-3. diff --git a/rules b/rules new file mode 100755 index 0000000..f3204a6 --- /dev/null +++ b/rules @@ -0,0 +1,7 @@ +#! /usr/bin/make -f + +export PYBUILD_NAME := macaroonbakery +export PYBUILD_TEST_PYTEST := 1 + +%: + dh $@ --with python3 --buildsystem=pybuild diff --git a/source/format b/source/format new file mode 100644 index 0000000..163aaf8 --- /dev/null +++ b/source/format @@ -0,0 +1 @@ +3.0 (quilt) diff --git a/upstream/metadata b/upstream/metadata new file mode 100644 index 0000000..62688c4 --- /dev/null +++ b/upstream/metadata @@ -0,0 +1,3 @@ +Bug-Database: https://github.com/go-macaroon-bakery/py-macaroon-bakery/issues +Bug-Submit: https://github.com/go-macaroon-bakery/py-macaroon-bakery/issues/new +Repository-Browse: https://github.com/go-macaroon-bakery/py-macaroon-bakery diff --git a/watch b/watch new file mode 100644 index 0000000..2f3e303 --- /dev/null +++ b/watch @@ -0,0 +1,3 @@ +version=4 +opts="uversionmangle=s/(rc|a|b|c)/~$1/" \ + https://pypi.debian.net/macaroonbakery/macaroonbakery@ANY_VERSION@@ARCHIVE_EXT@ -- cgit v1.2.3