├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── tests.yml ├── .gitignore ├── .hgignore ├── .hgtags ├── .readthedocs.yaml ├── LICENSE.txt ├── MANIFEST.in ├── README.rst ├── docs ├── Makefile ├── _static │ └── sidebar.js ├── _templates │ └── page.html ├── conf.py ├── index.rst ├── requirements.txt └── spelling_wordlist.txt ├── pagesign.py ├── pyproject.toml ├── setup.cfg └── test_pagesign.py /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve this library. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Environment** 27 | - OS, including version 28 | - Version of this library 29 | 30 | **Additional information** 31 | Add any other information about the problem here. 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | paths-ignore: 7 | - 'LICENSE.*' 8 | - 'README.*' 9 | - '.github/ISSUE-TEMPLATE/**' 10 | - 'docs/**' 11 | - '.hgignore' 12 | - '.gitignore' 13 | 14 | pull_request: 15 | branches: [ main ] 16 | paths-ignore: 17 | - 'LICENSE.*' 18 | - 'README.*' 19 | - '.github/ISSUE-TEMPLATE/**' 20 | - 'docs/**' 21 | - '.hgignore' 22 | - '.gitignore' 23 | 24 | schedule: # at 03:06 on day-of-month 6 25 | - cron: '6 3 6 * *' 26 | 27 | workflow_dispatch: 28 | 29 | jobs: 30 | build: 31 | runs-on: ${{ matrix.os }} 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | os: [ubuntu-latest, macos-latest, windows-latest] 36 | python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13', 'pypy-3.9'] 37 | 38 | steps: 39 | - uses: actions/checkout@v4 40 | - name: Set up Python ${{ matrix.python-version }} 41 | uses: actions/setup-python@v5 42 | with: 43 | python-version: ${{ matrix.python-version }} 44 | - name: Add Homebrew to PATH (Ubuntu) 45 | if: ${{ matrix.os == 'ubuntu-latest' }} 46 | run: | 47 | echo "/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin" >> $GITHUB_PATH 48 | - name: Set up age and minisign (POSIX) 49 | if: ${{ matrix.os != 'windows-latest' }} 50 | run: | 51 | brew install age minisign 52 | - name: Set up age and minisign (Windows) 53 | if: ${{ matrix.os == 'windows-latest' }} 54 | run: | 55 | choco install age.portable 56 | choco install minisign 57 | - name: Test with unittest 58 | run: | 59 | age --version 60 | minisign -v 61 | python test_pagesign.py 62 | - name: Test with coverage 63 | run: | 64 | pip install coverage 65 | coverage run --branch test_pagesign.py 66 | coverage xml 67 | - name: Upload coverage to Codecov 68 | uses: codecov/codecov-action@v4 69 | with: 70 | flags: unittests 71 | files: coverage.xml 72 | fail_ci_if_error: false 73 | token: ${{ secrets.CODECOV_TOKEN }} 74 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.pyo 3 | *.log 4 | __pycache__/ 5 | build/ 6 | docs/_build 7 | dist/ 8 | *.egg-info/ 9 | MANIFEST 10 | -------------------------------------------------------------------------------- /.hgignore: -------------------------------------------------------------------------------- 1 | \.(pyc|pyo|log|coverage|json|dict-validwords) 2 | (build|dist|.*egg-info|htmlcov|__pycache__|\.mypy_cache)/ 3 | ^MANIFEST$ 4 | -------------------------------------------------------------------------------- /.hgtags: -------------------------------------------------------------------------------- 1 | 2161640154bc4bbc2372e21c92a6c5c9dd67a7bf 0.1.0 2 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Set the version of Python and other tools you might need 9 | build: 10 | os: ubuntu-22.04 11 | tools: 12 | python: "3.11" 13 | 14 | # Build documentation in the docs/ directory with Sphinx 15 | sphinx: 16 | configuration: docs/conf.py 17 | 18 | # We recommend specifying your dependencies to enable reproducible builds: 19 | # https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html 20 | python: 21 | install: 22 | - requirements: docs/requirements.txt 23 | 24 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2021-2022 by Vinay Sajip. 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, 10 | this list of conditions and the following disclaimer. 11 | * Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | * The name(s) of the copyright holder(s) may not be used to endorse or 15 | promote products derived from this software without specific prior 16 | written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDER(S) "AS IS" AND ANY EXPRESS OR 19 | IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 20 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO 21 | EVENT SHALL THE COPYRIGHT HOLDER(S) BE LIABLE FOR ANY DIRECT, INDIRECT, 22 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 23 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 24 | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 25 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE 26 | OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 27 | ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | 29 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE.txt 2 | include README.rst 3 | include test_pagesign.py 4 | 5 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | |badge1| |badge2| |badge3| 2 | 3 | .. |badge1| image:: https://img.shields.io/github/actions/workflow/status/vsajip/pagesign/tests.yml 4 | :alt: GitHub test status 5 | 6 | .. |badge2| image:: https://img.shields.io/codecov/c/github/vsajip/pagesign 7 | :target: https://app.codecov.io/gh/vsajip/pagesign 8 | :alt: GitHub coverage status 9 | 10 | .. |badge3| image:: https://img.shields.io/pypi/v/pagesign 11 | :target: https://pypi.org/project/pagesign/ 12 | :alt: PyPI package 13 | 14 | 15 | What is it? 16 | =========== 17 | 18 | `age `_ and `minisign 19 | `_ are modern command-line programs which 20 | respectively provide support for encryption/decryption and signing/verification of 21 | data. It is possible to provide programmatic access to their functionality by spawning 22 | separate processes to run them and then communicating with those processes from your 23 | program. 24 | 25 | This project, ``pagesign`` (for 'Python-age-sign'), implements a Python library which 26 | takes care of the internal details and allows its users to generate and manage keys, 27 | encrypt and decrypt data, and sign and verify messages using ``age`` and ``minisign``. 28 | 29 | This library does not install ``age`` or ``minisign`` for you: you will need to 30 | install them yourself (see `the documentation 31 | `_ for more 32 | information). It expects functionality found in age v1.0.0 or later, and minisign v0.8 33 | or later. Three programs are expected to be found on the PATH: ``age-keygen``, ``age`` 34 | and ``minisign``. If any of them aren't found, this library won't work as expected. 35 | 36 | Installation 37 | ============ 38 | 39 | Installing from PyPI 40 | -------------------- 41 | 42 | You can install this package from the Python Package Index (pyPI) by running:: 43 | 44 | pip install pagesign 45 | 46 | 47 | Installing from a source distribution archive 48 | --------------------------------------------- 49 | To install this package from a source distribution archive, do the following: 50 | 51 | 1. Extract all the files in the distribution archive to some directory on your 52 | system. 53 | 2. In that directory, run ``pip install .``, referencing a suitable ``pip`` (e.g. one 54 | from a specific venv which you want to install to). 55 | 3. Optionally, run ``python test_pagesign.py`` to ensure that the package is 56 | working as expected. 57 | 58 | Credits 59 | ======= 60 | 61 | * The developers of ``age`` and ``minisign``. 62 | 63 | API Documentation 64 | ================= 65 | 66 | https://docs.red-dove.com/pagesign/ 67 | 68 | Change log 69 | ========== 70 | 71 | 0.1.1 72 | ----- 73 | 74 | Released: Not yet. 75 | 76 | * Add the ``CryptException`` class and code to raise it when an operation fails. 77 | 78 | * Make a change so that ``clear_identities()`` now takes no arguments. 79 | 80 | * Add ``encrypt_mem()`` and ``decrypt_mem()`` functions to perform operations in 81 | memory. 82 | 83 | * Use a better algorithm for encryption and signing at the same time. 84 | 85 | 0.1.0 86 | ----- 87 | 88 | Released: 2021-12-05 89 | 90 | * Initial release. 91 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | 15 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest remote apidocs 16 | 17 | help: 18 | @echo "Please use \`make ' where is one of" 19 | @echo " html to make standalone HTML files" 20 | @echo " dirhtml to make HTML files named index.html in directories" 21 | @echo " singlehtml to make a single large HTML file" 22 | @echo " pickle to make pickle files" 23 | @echo " json to make JSON files" 24 | @echo " htmlhelp to make HTML files and a HTML help project" 25 | @echo " qthelp to make HTML files and a qthelp project" 26 | @echo " devhelp to make HTML files and a Devhelp project" 27 | @echo " epub to make an epub" 28 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 29 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 30 | @echo " text to make text files" 31 | @echo " man to make manual pages" 32 | @echo " changes to make an overview of all changed/added/deprecated items" 33 | @echo " linkcheck to check all external links for integrity" 34 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 35 | 36 | clean: 37 | -rm -rf $(BUILDDIR)/* 38 | 39 | html: 40 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 41 | @echo 42 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 43 | 44 | pdoc: 45 | mkdir -p $(BUILDDIR)/html/apidocs 46 | pdoc -o $(BUILDDIR)/html/apidocs --no-show-source --docformat google --logo https://www.red-dove.com/assets/img/rdclogo.gif ../pagesign.py 47 | 48 | apidocs: 49 | docfrag --venv local_tools --libs .. pagesign -f hovertip > hover.json 50 | 51 | remote: 52 | rsync -avz $(BUILDDIR)/html/* vopal:~/apps/rdc_docs/pagesign 53 | 54 | spelling: 55 | $(SPHINXBUILD) -b spelling $(ALLSPHINXOPTS) $(BUILDDIR) 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Distlib.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Distlib.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/Distlib" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Distlib" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | make -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | text: 120 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 121 | @echo 122 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 123 | 124 | man: 125 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 126 | @echo 127 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 128 | 129 | changes: 130 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 131 | @echo 132 | @echo "The overview file is in $(BUILDDIR)/changes." 133 | 134 | linkcheck: 135 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 136 | @echo 137 | @echo "Link check complete; look for any errors in the above output " \ 138 | "or in $(BUILDDIR)/linkcheck/output.txt." 139 | 140 | doctest: 141 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 142 | @echo "Testing of doctests in the sources finished, look at the " \ 143 | "results in $(BUILDDIR)/doctest/output.txt." 144 | -------------------------------------------------------------------------------- /docs/_static/sidebar.js: -------------------------------------------------------------------------------- 1 | /* 2 | * sidebar.js 3 | * ~~~~~~~~~~ 4 | * 5 | * This script makes the Sphinx sidebar collapsible. 6 | * 7 | * .sphinxsidebar contains .sphinxsidebarwrapper. This script adds in 8 | * .sphixsidebar, after .sphinxsidebarwrapper, the #sidebarbutton used to 9 | * collapse and expand the sidebar. 10 | * 11 | * When the sidebar is collapsed the .sphinxsidebarwrapper is hidden and the 12 | * width of the sidebar and the margin-left of the document are decreased. 13 | * When the sidebar is expanded the opposite happens. This script saves a 14 | * per-browser/per-session cookie used to remember the position of the sidebar 15 | * among the pages. Once the browser is closed the cookie is deleted and the 16 | * position reset to the default (expanded). 17 | * 18 | * :copyright: Copyright 2007-2011 by the Sphinx team, see AUTHORS. 19 | * :license: BSD, see LICENSE for details. 20 | * 21 | */ 22 | 23 | $(function() { 24 | // global elements used by the functions. 25 | // the 'sidebarbutton' element is defined as global after its 26 | // creation, in the add_sidebar_button function 27 | var bodywrapper = $('.bodywrapper'); 28 | var sidebar = $('.sphinxsidebar'); 29 | var sidebarwrapper = $('.sphinxsidebarwrapper'); 30 | 31 | // original margin-left of the bodywrapper and width of the sidebar 32 | // with the sidebar expanded 33 | var bw_margin_expanded = bodywrapper.css('margin-left'); 34 | var ssb_width_expanded = sidebar.width(); 35 | 36 | // margin-left of the bodywrapper and width of the sidebar 37 | // with the sidebar collapsed 38 | var bw_margin_collapsed = '.8em'; 39 | var ssb_width_collapsed = '.8em'; 40 | 41 | // colors used by the current theme 42 | var dark_color = '#AAAAAA'; 43 | var light_color = '#CCCCCC'; 44 | 45 | function sidebar_is_collapsed() { 46 | return sidebarwrapper.is(':not(:visible)'); 47 | } 48 | 49 | function toggle_sidebar() { 50 | if (sidebar_is_collapsed()) 51 | expand_sidebar(); 52 | else 53 | collapse_sidebar(); 54 | } 55 | 56 | function collapse_sidebar() { 57 | sidebarwrapper.hide(); 58 | sidebar.css('width', ssb_width_collapsed); 59 | bodywrapper.css('margin-left', bw_margin_collapsed); 60 | sidebarbutton.css({ 61 | 'margin-left': '0', 62 | 'height': bodywrapper.height(), 63 | 'border-radius': '5px' 64 | }); 65 | sidebarbutton.find('span').text('»'); 66 | sidebarbutton.attr('title', _('Expand sidebar')); 67 | document.cookie = 'sidebar=collapsed'; 68 | } 69 | 70 | function expand_sidebar() { 71 | bodywrapper.css('margin-left', bw_margin_expanded); 72 | sidebar.css('width', ssb_width_expanded); 73 | sidebarwrapper.show(); 74 | sidebarbutton.css({ 75 | 'margin-left': ssb_width_expanded-12, 76 | 'height': bodywrapper.height(), 77 | 'border-radius': '0 5px 5px 0' 78 | }); 79 | sidebarbutton.find('span').text('«'); 80 | sidebarbutton.attr('title', _('Collapse sidebar')); 81 | //sidebarwrapper.css({'padding-top': 82 | // Math.max(window.pageYOffset - sidebarwrapper.offset().top, 10)}); 83 | document.cookie = 'sidebar=expanded'; 84 | } 85 | 86 | function add_sidebar_button() { 87 | sidebarwrapper.css({ 88 | 'float': 'left', 89 | 'margin-right': '0', 90 | 'width': ssb_width_expanded - 28 91 | }); 92 | // create the button 93 | sidebar.append( 94 | '
«
' 95 | ); 96 | var sidebarbutton = $('#sidebarbutton'); 97 | // find the height of the viewport to center the '<<' in the page 98 | var viewport_height; 99 | if (window.innerHeight) 100 | viewport_height = window.innerHeight; 101 | else 102 | viewport_height = $(window).height(); 103 | var sidebar_offset = sidebar.offset().top; 104 | var sidebar_height = Math.max(bodywrapper.height(), sidebar.height()); 105 | sidebarbutton.find('span').css({ 106 | 'display': 'block', 107 | 'position': 'fixed', 108 | 'top': Math.min(viewport_height/2, sidebar_height/2 + sidebar_offset) - 10 109 | }); 110 | 111 | sidebarbutton.click(toggle_sidebar); 112 | sidebarbutton.attr('title', _('Collapse sidebar')); 113 | sidebarbutton.css({ 114 | 'border-radius': '0 5px 5px 0', 115 | 'color': '#444444', 116 | 'background-color': '#CCCCCC', 117 | 'font-size': '1.2em', 118 | 'cursor': 'pointer', 119 | 'height': sidebar_height, 120 | 'padding-top': '1px', 121 | 'padding-left': '1px', 122 | 'margin-left': ssb_width_expanded - 12 123 | }); 124 | 125 | sidebarbutton.hover( 126 | function () { 127 | $(this).css('background-color', dark_color); 128 | }, 129 | function () { 130 | $(this).css('background-color', light_color); 131 | } 132 | ); 133 | } 134 | 135 | function set_position_from_cookie() { 136 | if (!document.cookie) 137 | return; 138 | var items = document.cookie.split(';'); 139 | for(var k=0; k 6 | 16 | 17 | Comments powered by Disqus 18 | 19 | {% endblock %} 20 | {% block footer %} 21 | {{ super() }} 22 | 30 | {% endblock %} 31 | 32 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # pagesign for Python documentation build configuration file. 4 | # 5 | # This file is execfile()d with the current directory set to its containing dir. 6 | # 7 | # Note that not all possible configuration values are present in this 8 | # autogenerated file. 9 | # 10 | # All configuration values have a default; values that are commented out 11 | # serve to show the default. 12 | 13 | import datetime, os, sys 14 | 15 | # If extensions (or modules to document with autodoc) are in another directory, 16 | # add these directories to sys.path here. If the directory is relative to the 17 | # documentation root, use os.path.abspath to make it absolute, like shown here. 18 | #sys.path.append(os.path.abspath('.')) 19 | 20 | # -- General configuration ----------------------------------------------------- 21 | 22 | # Add any Sphinx extension module names here, as strings. They can be extensions 23 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 24 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.doctest', 'sphinx.ext.intersphinx', 'sphinx.ext.todo', 'sphinx.ext.coverage', 25 | #'sphinx.ext.imgmath', 26 | 'sphinx.ext.ifconfig', 'sphinx.ext.viewcode', 27 | 'sphinxcontrib.spelling'] 28 | 29 | # Add any paths that contain templates here, relative to this directory. 30 | templates_path = ['_templates'] 31 | 32 | # The suffix of source filenames. 33 | source_suffix = '.rst' 34 | 35 | # The encoding of source files. 36 | #source_encoding = 'utf-8-sig' 37 | 38 | # The master toctree document. 39 | master_doc = 'index' 40 | 41 | # General information about the project. 42 | project = u'pagesign' 43 | copyright = u'2021-%s, Vinay Sajip' % datetime.date.today().year 44 | 45 | # The version info for the project you're documenting, acts as replacement for 46 | # |version| and |release|, also used in various other places throughout the 47 | # built documents. 48 | # 49 | sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) 50 | from pagesign import __version__ as release, __date__ as today 51 | version = '.'.join(release.split('.')[:2]) 52 | if '.dev' in release: 53 | today = datetime.date.today().strftime('%b %d, %Y') 54 | else: 55 | today = today.split()[0][1:].split('-') 56 | today = '%s %s, %s' % (today[1], today[0], today[2]) 57 | 58 | # The language for content autogenerated by Sphinx. Refer to documentation 59 | # for a list of supported languages. 60 | #language = None 61 | 62 | # There are two options for replacing |today|: either, you set today to some 63 | # non-false value, then it is used: 64 | #today = '' 65 | # Else, today_fmt is used as the format for a strftime call. 66 | #today_fmt = '%B %d, %Y' 67 | 68 | # List of patterns, relative to source directory, that match files and 69 | # directories to ignore when looking for source files. 70 | exclude_patterns = ['_build'] 71 | 72 | # The reST default role (used for this markup: `text`) to use for all documents. 73 | #default_role = None 74 | 75 | # If true, '()' will be appended to :func: etc. cross-reference text. 76 | #add_function_parentheses = True 77 | 78 | # If true, the current module name will be prepended to all description 79 | # unit titles (such as .. function::). 80 | add_module_names = False 81 | 82 | # If true, sectionauthor and moduleauthor directives will be shown in the 83 | # output. They are ignored by default. 84 | #show_authors = False 85 | 86 | # The name of the Pygments (syntax highlighting) style to use. 87 | pygments_style = 'sphinx' 88 | 89 | # A list of ignored prefixes for module index sorting. 90 | #modindex_common_prefix = [] 91 | 92 | spelling_lang='en_GB' 93 | spelling_word_list_filename='spelling_wordlist.txt' 94 | 95 | 96 | # -- Options for HTML output --------------------------------------------------- 97 | 98 | HTML_THEME_OPTIONS = { 99 | 'sizzle': { 100 | 'sitemap_url': 'https://docs.red-dove.com/pagesign/' 101 | } 102 | } 103 | 104 | # The theme to use for HTML and HTML Help pages. See the documentation for 105 | # a list of builtin themes. 106 | html_theme = os.environ.get('DOCS_THEME', 'default') 107 | 108 | if html_theme == 'sizzle' and os.path.isfile('hover.json'): 109 | import json 110 | 111 | with open('hover.json', encoding='utf-8') as f: 112 | HTML_THEME_OPTIONS['sizzle']['custom_data'] = {'hovers': json.load(f) } 113 | 114 | # Theme options are theme-specific and customize the look and feel of a theme 115 | # further. For a list of options available for each theme, see the 116 | # documentation. 117 | if html_theme in HTML_THEME_OPTIONS: 118 | html_theme_options = HTML_THEME_OPTIONS[html_theme] 119 | 120 | # Add any paths that contain custom themes here, relative to this directory. 121 | html_theme_path = ['themes'] 122 | 123 | # The name for this set of Sphinx documents. If None, it defaults to 124 | # " v documentation". 125 | #html_title = None 126 | 127 | # A shorter title for the navigation bar. Default is the same as html_title. 128 | #html_short_title = None 129 | 130 | # The name of an image file (relative to this directory) to place at the top 131 | # of the sidebar. 132 | #html_logo = None 133 | 134 | # The name of an image file (within the static path) to use as favicon of the 135 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 136 | # pixels large. 137 | #html_favicon = None 138 | 139 | # Add any paths that contain custom static files (such as style sheets) here, 140 | # relative to this directory. They are copied after the builtin static files, 141 | # so a file named "default.css" will overwrite the builtin "default.css". 142 | html_static_path = ['_static'] 143 | 144 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 145 | # using the given strftime format. 146 | #html_last_updated_fmt = '%b %d, %Y' 147 | 148 | # If true, SmartyPants will be used to convert quotes and dashes to 149 | # typographically correct entities. 150 | #html_use_smartypants = True 151 | 152 | # Custom sidebar templates, maps document names to template names. 153 | 154 | # html_sidebars = { 155 | # '**': [ 156 | # 'localtoc.html', 'globaltoc.html', 'relations.html', 157 | # 'sourcelink.html', 'searchbox.html' 158 | # ], 159 | # } 160 | 161 | # Additional templates that should be rendered to pages, maps page names to 162 | # template names. 163 | #html_additional_pages = {} 164 | 165 | # If false, no module index is generated. 166 | #html_domain_indices = True 167 | 168 | # If false, no index is generated. 169 | #html_use_index = True 170 | 171 | # If true, the index is split into individual pages for each letter. 172 | #html_split_index = False 173 | 174 | # If true, links to the reST sources are added to the pages. 175 | #html_show_sourcelink = True 176 | 177 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 178 | #html_show_sphinx = True 179 | 180 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 181 | #html_show_copyright = True 182 | 183 | # If true, an OpenSearch description file will be output, and all pages will 184 | # contain a tag referring to it. The value of this option must be the 185 | # base URL from which the finished HTML is served. 186 | #html_use_opensearch = '' 187 | 188 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 189 | #html_file_suffix = None 190 | 191 | # Output file base name for HTML help builder. 192 | htmlhelp_basename = 'AgeMinisignWrapperforPythondoc' 193 | 194 | 195 | # -- Options for LaTeX output -------------------------------------------------- 196 | 197 | # The paper size ('letter' or 'a4'). 198 | #latex_paper_size = 'letter' 199 | 200 | # The font size ('10pt', '11pt' or '12pt'). 201 | #latex_font_size = '10pt' 202 | 203 | # Grouping the document tree into LaTeX files. List of tuples 204 | # (source start file, target name, title, author, documentclass [howto/manual]). 205 | latex_documents = [ 206 | ('index', 'AgeMinisignWrapperforPython.tex', u'Age/Minisign Wrapper for Python Documentation', 207 | u'Vinay Sajip', 'manual'), 208 | ] 209 | 210 | # The name of an image file (relative to this directory) to place at the top of 211 | # the title page. 212 | #latex_logo = None 213 | 214 | # For "manual" documents, if this is true, then toplevel headings are parts, 215 | # not chapters. 216 | #latex_use_parts = False 217 | 218 | # If true, show page references after internal links. 219 | #latex_show_pagerefs = False 220 | 221 | # If true, show URL addresses after external links. 222 | #latex_show_urls = False 223 | 224 | # Additional stuff for the LaTeX preamble. 225 | #latex_preamble = '' 226 | 227 | # Documents to append as an appendix to all manuals. 228 | #latex_appendices = [] 229 | 230 | # If false, no module index is generated. 231 | #latex_domain_indices = True 232 | 233 | 234 | # -- Options for manual page output -------------------------------------------- 235 | 236 | # One entry per manual page. List of tuples 237 | # (source start file, name, description, authors, manual section). 238 | man_pages = [ 239 | ('index', 'pagesign', u'pagesign Documentation', 240 | [u'Vinay Sajip'], 1) 241 | ] 242 | 243 | 244 | # -- Options for Epub output --------------------------------------------------- 245 | 246 | # Bibliographic Dublin Core info. 247 | epub_title = u'pagesign' 248 | epub_author = u'Vinay Sajip' 249 | epub_publisher = u'Vinay Sajip' 250 | epub_copyright = u'2021, Vinay Sajip' 251 | 252 | # The language of the text. It defaults to the language option 253 | # or en if the language is not set. 254 | #epub_language = '' 255 | 256 | # The scheme of the identifier. Typical schemes are ISBN or URL. 257 | #epub_scheme = '' 258 | 259 | # The unique identifier of the text. This can be a ISBN number 260 | # or the project homepage. 261 | #epub_identifier = '' 262 | 263 | # A unique identification for the text. 264 | #epub_uid = '' 265 | 266 | # HTML files that should be inserted before the pages created by sphinx. 267 | # The format is a list of tuples containing the path and title. 268 | #epub_pre_files = [] 269 | 270 | # HTML files shat should be inserted after the pages created by sphinx. 271 | # The format is a list of tuples containing the path and title. 272 | #epub_post_files = [] 273 | 274 | # A list of files that should not be packed into the epub file. 275 | #epub_exclude_files = [] 276 | 277 | # The depth of the table of contents in toc.ncx. 278 | #epub_tocdepth = 3 279 | 280 | # Allow duplicate toc entries. 281 | #epub_tocdup = True 282 | 283 | 284 | # Example configuration for intersphinx: refer to the Python standard library. 285 | intersphinx_mapping = {'python': ('http://docs.python.org/', None)} 286 | 287 | def skip_module_docstring(app, what, name, obj, options, lines): 288 | if (what, name) == ('module', 'distlib'): 289 | del lines[:] 290 | 291 | def setup(app): 292 | app.connect('autodoc-process-docstring', skip_module_docstring) 293 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. Age/Minisign Wrapper for Python documentation master file. 2 | 3 | ###################################################### 4 | `pagesign` - A Python wrapper for `age` and `minisign` 5 | ###################################################### 6 | 7 | .. rst-class:: release-info 8 | 9 | .. list-table:: 10 | :widths: auto 11 | :stub-columns: 1 12 | 13 | * - Release: 14 | - |release| 15 | * - Date: 16 | - |today| 17 | 18 | .. |--| unicode:: U+2013 19 | 20 | .. module:: pagesign 21 | :synopsis: A Python wrapper for age and minisign 22 | 23 | .. moduleauthor:: Vinay Sajip 24 | .. sectionauthor:: Vinay Sajip 25 | 26 | 27 | The ``pagesign`` (for 'Python-age-sign') module allows Python programs to make use of 28 | the functionality provided by the modern cryptography tools `age 29 | `_ and `minisign 30 | `_. Using this module, Python programs can 31 | encrypt and decrypt data, digitally sign documents, verify digital signatures, 32 | and manage (generate, list and delete) encryption and signing keys. 33 | 34 | This module is expected to be used with Python versions >= 3.6. Install this module 35 | using ``pip install pagesign``. You can then use this module in your own code by 36 | doing ``import pagesign`` or similar. 37 | 38 | .. index:: Deployment 39 | 40 | .. _deployment: 41 | 42 | Deployment Requirements 43 | ======================= 44 | 45 | Apart from a recent-enough version of Python, in order to use this module you need to 46 | have access to a compatible versions of `age-keygen`, `age` and `minisign` executables. 47 | The system has been tested with `age` later than v1.0.0 and `minisign` later than v0.8 on 48 | Windows, macOS and Ubuntu. You can see test runs (which show the versions of `age` and 49 | `minisign` used) `here `__. 50 | 51 | .. index:: Acknowledgements 52 | 53 | Acknowledgements 54 | ================ 55 | 56 | The ``pagesign`` module follows a similar approach to `python-gnupg 57 | `_ (by the same author), and uses Python's 58 | ``subprocess`` module to communicate with the `age-keygen`, `age` and `minisign` 59 | executables, which it uses to spawn subprocesses to do the real work of key creation, 60 | encryption, decryption, signing and verification. 61 | 62 | Of course this module wouldn't exist without the great work by the `age` and `minisign` 63 | developers. 64 | 65 | Installation 66 | ============ 67 | 68 | Installing from PyPI 69 | -------------------- 70 | 71 | You can install this package from the Python Package Index (PyPI) by running:: 72 | 73 | pip install pagesign 74 | 75 | 76 | Installing from a source distribution archive 77 | --------------------------------------------- 78 | To install this package from a source distribution archive, do the following: 79 | 80 | 1. Extract all the files in the distribution archive to some directory on your 81 | system. 82 | 2. In that directory, run ``pip install .``, referencing a suitable ``pip`` (e.g. one 83 | from a specific venv which you want to install to). 84 | 3. Optionally, run ``python test_pagesign.py`` to ensure that the package is 85 | working as expected. 86 | 87 | Installing ``age`` 88 | ------------------ 89 | 90 | You can get binary releases of the latest version of ``age`` for Linux, macOS and 91 | Windows from `here `__. Alternatively, 92 | you might be able to use package managers such as your distro package manager (Linux), 93 | `MacPorts `__ or `Homebrew 94 | `__ (macOS) or `Chocolatey 95 | `__ (Windows). 96 | 97 | Installing ``minisign`` 98 | ----------------------- 99 | 100 | You can get binary releases of the latest version of ``minisign`` for Linux, macOS and 101 | Windows from `here `__. Alternatively, you might 102 | be able to use package managers such as your distro package manager (Linux), `MacPorts 103 | `__ or `Homebrew 104 | `__ (macOS) or `Chocolatey 105 | `__ (Windows). 106 | 107 | 108 | Before you Start 109 | ================ 110 | 111 | `pagesign` works on the basis of a "home directory" which is used to store public and 112 | secret key data. (Whereas `age` and `minisign` will save created keys in files for 113 | you, but nothing beyond that, `pagesign` will allow you to refer to `identities` using 114 | simple names). The directory on POSIX systems is `~/.pagesign` and on Windows is 115 | `%LOCALAPPDATA%\\pagesign`. If this directory doesn't exist, it is created. On POSIX, 116 | its permissions are set so only the owner has full access, and everyone else has no 117 | access (permission mask of octal 0700). 118 | 119 | This directory will contain an identity store (called `keystore` from now on, as it 120 | mainly holds keys). On POSIX, its permissions are set so only the owner has full 121 | access, and everyone else has no access (permission mask of octal 0600). 122 | 123 | Although identity names could be email addresses (as used with GnuPG, for example) but 124 | they could equally be things like `'project-X-signing'` or similar, reflecting 125 | their function rather than a person or organisation. However, keys that are exported 126 | for sharing and then imported should be saved with a name that indicates unambiguously 127 | what they are / where they're from. 128 | 129 | .. index:: Getting started 130 | 131 | Getting Started 132 | =============== 133 | 134 | You interface to the `age` and `minisign` functionality through the following items in 135 | the `pagesign` module: 136 | 137 | * The :class:`~pagesign.Identity` class. 138 | 139 | * The :func:`~pagesign.encrypt`, :func:`~pagesign.decrypt`, :func:`~pagesign.sign` and 140 | :func:`~pagesign.verify` functions. (There are other functions you can use, 141 | but those are the main ones.) 142 | 143 | Identity Management 144 | =================== 145 | 146 | The :class:`~pagesign.Identity` class represents an identity, which can either be a local identity 147 | (which has access to secret keys and passphrases in order to decrypt and sign things) 148 | or a remote identity (which only has public keys, so it can only be used to encrypt and 149 | verify things). 150 | 151 | A remote identity consists of: 152 | 153 | * A string indicating the creation time of the identity in `YYYY-mm-ddTHH:MM:SSZ` 154 | format. 155 | * A public key (from `age`) for encrypting files. 156 | * A public key (from `minisign`) for verifying file signatures. 157 | * A signature ID (from `minisign`) |--| this is not currently used. 158 | 159 | A local identity, in addition to the above, contains: 160 | 161 | * A secret key (from `age`) for decrypting files. 162 | * A secret key (from `minisign`) for signing files. 163 | * A passphrase (created automatically by `pagesign` and used for signing). This is 164 | needed to use `minisign`'s secret key. 165 | 166 | These are stored in attributes of an :class:`~pagesign.Identity` instance named `created`, 167 | `crypt_public`, `sign_public`, `sign_id`, `crypt_secret`, `sign_secret` and 168 | `sign_pass`. Creation of a local identity generates four keys |--| two secret and two 169 | public, two for encryption/decryption and two for signing/verification. The following 170 | table illustrates what they're for. 171 | 172 | .. cssclass:: generic-table table-bordered table-striped table-responsive-sm colwidths-auto mx-auto 173 | 174 | +---------------------------------+----------------------+ 175 | | Attribute | Used for ... | 176 | +=================================+======================+ 177 | | `crypt_public` (from `age`) | Encrypting data | 178 | +---------------------------------+----------------------+ 179 | | `crypt_secret` (from `age`) | Decrypting data | 180 | +---------------------------------+----------------------+ 181 | | `sign_public` (from `minisign`) | Verifying signatures | 182 | +---------------------------------+----------------------+ 183 | | `sign_secret` (from `minisign`) | Signing data | 184 | +---------------------------------+----------------------+ 185 | 186 | .. raw:: html 187 | 188 | 196 | 197 | Generating identities 198 | --------------------- 199 | 200 | To create a new local identity, you simply call 201 | 202 | .. code-block:: python 203 | 204 | from pagesign import Identity 205 | identity = Identity() 206 | 207 | Once you've called this, the identity is in memory, but not saved anywhere. To save it, 208 | you call its `save()` method with a name |--| just a string you choose. It could be a 209 | simple identifier like `alice` or `bob`, or an email address. 210 | 211 | .. code-block:: python 212 | 213 | identity.save('bob') 214 | 215 | This saves the identity under the name `bob`. To get it back at a later time, pass it 216 | to the `Identity` constructor: 217 | 218 | .. code-block:: python 219 | 220 | bob = Identity('bob') 221 | 222 | The `save()` method saves the local identity in a keystore which is stored in the 223 | `pagesign` home directory mentioned earlier. Passing that name to the constructor just 224 | retrieves it from the store. If you pass a name that's not in the keystore, you will 225 | get an error. 226 | 227 | The keystore is currently just a plaintext file in JSON format. It relies on directory 228 | and file permissions for keeping your secret keys secret. 229 | 230 | .. index:: 231 | single: Key; performance issues 232 | single: Entropy 233 | 234 | Performance Issues 235 | ------------------ 236 | 237 | Key generation requires the system to work with a source of random numbers. Systems 238 | which are better at generating random numbers than others are said to have higher 239 | *entropy*. This is typically obtained from the system hardware; keys should usually be 240 | generated *only* on a local machine (i.e. not one being accessed across a network), 241 | and that keyboard, mouse and disk activity be maximised during key generation to 242 | increase the entropy of the system. 243 | 244 | Unfortunately, there are some scenarios |--| for example, on virtual machines which 245 | don't have real hardware - where insufficient entropy can cause key generation to be 246 | slow. If you come across this problem, you should investigate means of increasing the 247 | system entropy. On virtualised Linux systems, this can often be achieved by installing 248 | the ``rng-tools`` package. This is available at least on RPM-based and APT-based 249 | systems (Red Hat/Fedora, Debian, Ubuntu and derivative distributions). 250 | 251 | 252 | .. index:: Key; exporting 253 | 254 | Exporting identities 255 | -------------------- 256 | 257 | You can export the public parts of an identity to send to someone. To do this, you call 258 | the :meth:`~pagesign.Identity.export` method of an instance: 259 | 260 | .. code-block:: python 261 | 262 | exported = identity.export() 263 | 264 | This returns a dictionary which contains the public attributes of the identity, whose 265 | keys are the attribute names mentioned earlier. 266 | 267 | Importing identities 268 | -------------------- 269 | 270 | If you receive a dictionary representing an exported identity from someone, you 271 | can import it into your local keystore by calling the class method 272 | :meth:`~pagesign.Identity.imported`: 273 | 274 | .. code-block:: python 275 | 276 | alice = Identity.imported(sent_by_alice, 'alice') 277 | 278 | This saves the remote identity in the keystore with the given name. You (`bob`, say) 279 | can use this when exchanging information with `alice`. 280 | 281 | Deleting identities 282 | ------------------- 283 | 284 | If you want to completely get rid of an identity, you can call the 285 | :func:`~pagesign.remove_identities` function. To remove all identities from the 286 | keystore, the :func:`~pagesign.clear_identities()` function is used. 287 | 288 | .. code-block:: python 289 | 290 | from pagesign import remove_identities, clear_identities 291 | 292 | remove_identities('bob', 'alice') # removes just these two 293 | clear_identities() # removes everything 294 | 295 | There is no way to undo these operations, so be careful! 296 | 297 | .. index:: Key; listing 298 | 299 | Listing identities 300 | ------------------ 301 | 302 | Now that we've seen how to create, import and export identities, let's move on to 303 | finding which identities we have in our keystore. This is fairly straightforward 304 | using :func:`~pagesign.list_identities`: 305 | 306 | .. code-block:: python 307 | 308 | from pagesign import list_identities 309 | 310 | identities = list_identities() 311 | 312 | This returns an iterable of `(name, info)` tuples in random order. The `name` is the 313 | identity name, and the `info` is a dictionary of all the identity attributes for that 314 | identity. 315 | 316 | The `Identity` class 317 | -------------------- 318 | 319 | The `Identity` class API is here: 320 | 321 | .. class:: Identity 322 | 323 | .. cssclass:: class-members-heading 324 | 325 | Attributes 326 | 327 | .. attribute:: Identity.created : str 328 | 329 | This attribute is a string indicating when the identity was created. 330 | 331 | .. attribute:: Identity.crypt_public : str 332 | 333 | This attribute is the public key used for encryption. 334 | 335 | .. attribute:: Identity.sign_public : str 336 | 337 | This attribute is the public key used for signature verification. 338 | 339 | .. attribute:: Identity.sign_id : str 340 | 341 | This attribute is a key ID which is generated by `minisign` but not currently 342 | used in `pagesign`. 343 | 344 | .. attribute:: Identity.sign_pass : str 345 | 346 | This attribute is a passphrase automatically generated by `pagesign` and used 347 | for signing. It should not be shared with the wrong people, else they could 348 | impersonate you when signing stuff. 349 | 350 | .. attribute:: Identity.crypt_secret : str 351 | 352 | This attribute is the secret key used for decryption. It should not be shared 353 | with the wrong people, else they can decrypt stuff meant only for you. 354 | 355 | .. attribute:: Identity.sign_secret : str 356 | 357 | This attribute is the secret key used for signing. It should not be shared with 358 | the wrong people, else they could impersonate you when signing stuff. 359 | 360 | .. cssclass:: class-members-heading 361 | 362 | Methods 363 | 364 | .. method:: Identity.__init__(name : Optional[str] = None) -> Identity 365 | 366 | If `name` is specified, create an instance populated from data in the 367 | keystore associated with that name. Otherwise, create a new instance with 368 | autogenerated keys for signing and encryption (the key generation takes 369 | half a second). To persist such an instance, call its 370 | :meth:`~pagesign.Identity.save` method with a name of your choice. 371 | 372 | .. method:: Identity.export() -> dict[str, str] 373 | 374 | Return the public elements of this instance as a dictionary. The dictionary keys 375 | match the attribute names listed earlier. 376 | 377 | .. method:: Identity.save(name : str) -> None 378 | 379 | Save this instance as a dictionary in the keystore against `name`, overwriting 380 | any existing data under that name. 381 | 382 | .. classmethod:: imported(public_data : dict[str, str]) -> Identity 383 | 384 | This is a factory method which generates an :class:`~pagesign.Identity` instance from the 385 | dictionary `public_data`. The instance isn't saved in your keystore until you 386 | call its :meth:`~pagesign.Identity.save` method with a name of your choice. 387 | 388 | 389 | Exceptions 390 | ========== 391 | 392 | Currently, all operations which fail raise instances of 393 | :class:`~pagesign.CryptException`, which is a subclass of ``Exception`` and 394 | currently does not add any functionality to it. 395 | 396 | 397 | Encryption and Decryption 398 | ========================= 399 | 400 | Data intended for some particular recipients is encrypted with the public keys of 401 | those recipients. Each recipient can decrypt the encrypted data using the 402 | corresponding secret key. A recipient is denoted by a local or remote identity. 403 | 404 | .. index:: Encryption 405 | 406 | Encryption 407 | ---------- 408 | 409 | To encrypt data, use the `encrypt` function: 410 | 411 | .. function:: encrypt(path: str, recipients: Union[str, list[str]], outpath: Optional[str] = None, armor: bool = False) -> str 412 | 413 | Encrypt a file at `path` to `outpath`. If `outpath` isn't specified, the value of 414 | `path` with `'.age'` appended is used. If `armor` is `True`, the output file is PEM 415 | encoded. The `recipients` can be a single identity name or a list or tuple of 416 | identity names. The encrypted file will be decryptable by any of the recipient 417 | identities. 418 | 419 | The function returns `outpath` if successful and raises an exception if not. 420 | 421 | .. note:: Although `age` supports encryption and decryption using passphrases, that 422 | is currently not supported here because there is currently no way to pass in a 423 | passphrase to `age` using a subprocess pipe. 424 | 425 | .. index:: Decryption 426 | 427 | Decryption 428 | ---------- 429 | 430 | To decrypt data, use the `decrypt` function: 431 | 432 | .. function:: decrypt(path: str, identities: Union[str, list[str]], outpath: Optional[str] = None) -> str 433 | 434 | Decrypt a file at `path` to `outpath`. If `outpath` isn't specified, then if `path` 435 | ends with `.age`, it is stripped to compute `outpath` |--| otherwise it has `'.dec'` 436 | appended to determine `outpath`. The `identities` can be a single identity name or 437 | a list or tuple of identity names. 438 | 439 | The function returns `outpath` if successful and raises an exception if not. 440 | 441 | .. index:: 442 | single: Memory; encrypting and decrypting in 443 | 444 | Encryption and Decryption in memory 445 | =================================== 446 | 447 | You can encrypt and decrypt in memory using the following functions: 448 | 449 | .. function:: encrypt_mem(data: Union[str, bytes] , recipients: Union[str, list[str]], armor: bool = False) -> bytes 450 | 451 | Encrypt data in `data` and return the encrypted value as a bytestring. If `data` is 452 | a string, it is encoded to binary using UTF-8 encoding. If `armor` is `True`, the 453 | output is PEM encoded. The `recipients` can be a single identity name or a list or 454 | tuple of identity names. The encrypted result will be decryptable by any of the 455 | recipient identities. 456 | 457 | .. versionadded:: 0.1.1 458 | 459 | .. function:: decrypt_mem(data: Union[str, bytes] , identities: Union[str, list[str]]) -> bytes 460 | 461 | Decrypt data in `data` and return the decrypted value as a bytestring. If `data` is 462 | a string, it is encoded to binary using UTF-8 encoding. (This really only makes 463 | sense if the encrypted data is in PEM format.) The `identities` can be a single 464 | identity name or a list or tuple of identity names. 465 | 466 | .. versionadded:: 0.1.1 467 | 468 | Signing and Verification 469 | ======================== 470 | 471 | Data intended for digital signing is signed with the secret key of the signer. Each 472 | recipient can verify the signed data using the corresponding public key. 473 | 474 | Signatures are always stored 'detached', i.e. in separate files from what they are 475 | signing. 476 | 477 | .. note:: Although encryption and decryption can be performed in memory, there is no 478 | analogous in-memory API for signing and verification, because `minisign` only signs 479 | and verifies signature files against source files and identities. 480 | 481 | .. index:: Signing 482 | 483 | Signing 484 | ------- 485 | 486 | To sign some data, use the `sign()` function: 487 | 488 | .. function:: sign(path: str, identity: str, outpath: Optional[str] = None) -> str 489 | 490 | Sign the file at `path` using `identity` as the signer. Write the signature to 491 | `outpath`. If `outpath` isn't specified, it is computed by appending `'.sig'` to 492 | `path`. 493 | 494 | The function returns `outpath` if successful and raises an exception if not. 495 | 496 | .. index:: Verification 497 | 498 | Verification 499 | ------------ 500 | 501 | To verify some data which you've received, use the `verify()` function: 502 | 503 | .. function:: verify(path: str, identity: str, sigpath: Optional[str] = None) 504 | 505 | Verify that the file at `path` was signed by `identity` using the signature in 506 | `sigpath`. If `sigpath` isn't specified, it is computed by appending `'.sig'` to 507 | `path`. 508 | 509 | The function raises an exception if verification fails. 510 | 511 | .. index:: 512 | single: Operations; combining encryption and signing 513 | 514 | Combining operations 515 | ==================== 516 | 517 | Often, you may want to combine encryption and signing, or verification before 518 | decryption. However, please note the caveats listed in :ref:`problems`. 519 | 520 | Using signing and encryption together 521 | ------------------------------------- 522 | 523 | If you want to use signing and encryption together, use `encrypt_and_sign()`: 524 | 525 | .. function:: encrypt_and_sign(path: str, recipients: Union[str, list[str]], signer: str, armor: bool = False, outpath: Optional[str] = None, sigpath: Optional[str] = None) -> [str, str] 526 | 527 | Encrypt and sign the file at `path` for `recipients` and sign with identity 528 | `signer`. Place the encrypted output at `outpath` and the signature in `sigpath`. 529 | 530 | If `armor` is `True`, the encrypted output is PEM encoded. 531 | 532 | If `outpath` isn't specified, it is computed by appending `'.age'` to `path`. 533 | If `sigpath` isn't specified, it is computed by appending `'.sig'` to `outpath`. 534 | 535 | The function returns `(outpath`, sigpath)` if successful and raises an exception if 536 | not. 537 | 538 | .. versionchanged:: 0.1.1 539 | 540 | The algorithm has changed from a naïve encrypt and sign operation to: 541 | 542 | #. Sign the plaintext. 543 | #. Construct a JSON object of the base64-encoded plaintext and signature. 544 | #. Encrypt that. 545 | #. Compute the SHA-256 hashes of all recipients' public keys into an array. 546 | #. Construct a JSON object of the encrypted data and hashes. 547 | #. Sign that and save it and its signature. 548 | 549 | To reverse the process, you need to use :func:`~pagesign.verify_and_decrypt`. 550 | 551 | This corresponds to `Section 5.2 of the Davis paper 552 | `_. 553 | 554 | Using verification and decryption together 555 | ------------------------------------------ 556 | 557 | As a counterpart to :func:`~pagesign.encrypt_and_sign`, there's also `verify_and_decrypt()`: 558 | 559 | .. function:: verify_and_decrypt(path: str, recipients: Union[str, list[str]], signer: str, outpath: Optional[str] = None, sigpath: Optional[str] = None) -> str 560 | 561 | Verify and decrypt the file at `path` for `recipients` and signed with identity 562 | `signer`. Place the decrypted output at `outpath` and use the signature in 563 | `sigpath`. 564 | 565 | If `sigpath` isn't specified, it is computed by appending `'.sig'` to `path`. 566 | If `outpath` isn't specified, it is computed as in :func:`~pagesign.decrypt`. 567 | 568 | The function returns `outpath` if successful and raises an exception if not. 569 | 570 | .. versionchanged:: 0.1.1 571 | 572 | The files passed to this function must have been produced by 573 | :func:`~pagesign.encrypt_and_sign`, as we need to reverse the algorithm which is applied there. 574 | 575 | .. index:: 576 | single: Caveats; combining encryption and signing 577 | 578 | .. _problems: 579 | 580 | Problems with naïve combination of signing and encryption 581 | ========================================================= 582 | 583 | Naïvely combining encryption and signing can lead to problems. These are described in 584 | some depth in `Don Davis' paper on the subject `_. While 585 | `pagesign` provides access to encryption and signing primitives and allows a 586 | relatively easy means of combining them, the actual data to be encrypted and signed 587 | needs to be constructed with care. The solutions proposed in `Section 5 of Davis' 588 | paper `_ involve combining data with identities 589 | during signing and encryption. 590 | 591 | The current implementation of :func:`encrypt_and_sign` uses a sign/encrypt/sign 592 | strategy (`Section 5.2 of Davis' 593 | paper `_), which involves the following steps. 594 | 595 | 1. Sign the plaintext. 596 | 2. Construct a JSON of the base64-encoded plaintext and signature. 597 | 3. Encrypt that. 598 | 4. Hash all the recipient public keys into a list. 599 | 5. Construct a JSON of the encrypted data and recipient hashes. 600 | 6. Sign that. 601 | 602 | The output from a :func:`~pagesign.encrypt_and_sign` might look something like 603 | this: 604 | 605 | .. code-block:: text 606 | 607 | { 608 | "encrypted": "YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0 ... z1LMsTB83iIVZYPzEgUomGx0Q", 609 | "armored": false, 610 | "recipients": [ 611 | "0e6f8764a139c9a8fd90c3ee4a40c69d0c2a638756485911ad9660a593410442" 612 | ] 613 | } 614 | 615 | In the above, the opaque ``encrypted`` value will be the result of encrypting a JSON 616 | which looks like 617 | 618 | .. code-block:: text 619 | 620 | { 621 | "plaintext": 622 | "signature": 623 | } 624 | 625 | 626 | Key distribution 627 | ================ 628 | 629 | The question of key distribution in a trustworthy way is currently out of scope for 630 | `pagesign` |--| you are expected to get exported keys securely to people you need to 631 | exchange data with, and they are expected to get their public keys to you securely. 632 | 633 | .. index:: Logging 634 | 635 | .. _logging: 636 | 637 | Logging 638 | ======= 639 | 640 | The module makes use of the facilities provided by Python's ``logging`` package. A 641 | single logger is created with the module's ``__name__``, hence ``pagesign`` unless you 642 | rename the module. 643 | 644 | .. index:: Download 645 | 646 | Test Harness 647 | ============ 648 | 649 | The distribution includes a test harness, ``test_pagesign.py``, which contains unit 650 | tests covering the functionality described above. 651 | 652 | .. note:: If you run the test harness, it will create a log file `test_pagesign.log` 653 | in a `logs` subdirectory under your home directory. 654 | 655 | Download 656 | ======== 657 | 658 | The latest version is available from the PyPI_ page. 659 | 660 | .. _PyPI: https://pypi.python.org/pypi/pagesign 661 | 662 | Status and Further Work 663 | ======================= 664 | 665 | The ``pagesign`` module is quite usable, though in its early stages and with the API 666 | still a little fluid. How this module evolves will be determined by feedback from its 667 | user community. 668 | 669 | If you find bugs and want to raise issues, or want to suggest improvements, please do 670 | so `here `__. All feedback will 671 | be gratefully received. 672 | 673 | The source code repository is `here `__. 674 | 675 | .. cssclass:: hidden 676 | 677 | .. cssclass:: hidden 678 | 679 | .. toctree:: 680 | :maxdepth: 4 681 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinxcontrib-spelling==7.6.2 2 | sphinx-rtd-theme>=1.2.2 3 | -------------------------------------------------------------------------------- /docs/spelling_wordlist.txt: -------------------------------------------------------------------------------- 1 | macOS 2 | PyPI 3 | keystore 4 | iterable 5 | autogenerated 6 | decryptable 7 | bytestring 8 | sigpath 9 | -------------------------------------------------------------------------------- /pagesign.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2021-2022 Red Dove Consultants Limited 4 | # 5 | """ 6 | This module supports key management, encryption/decryption and signing/verification 7 | using age and minisign. 8 | """ 9 | import base64 10 | import functools 11 | import hashlib 12 | import json 13 | import logging 14 | import os 15 | from pathlib import Path 16 | import re 17 | import shutil 18 | import subprocess 19 | import sys 20 | import tempfile 21 | import threading 22 | 23 | __version__ = '0.1.1.dev0' 24 | __author__ = 'Vinay Sajip' 25 | __date__ = "$05-Dec-2021 12:39:53$" 26 | 27 | if sys.version_info[:2] < (3, 6): # pragma: no cover 28 | raise ImportError('This module requires Python >= 3.6 to run.') 29 | 30 | logger = logging.getLogger(__name__) 31 | 32 | __all__ = [ 33 | 'Identity', 'CryptException', 'remove_identities', 'clear_identities', 34 | 'list_identities', 'encrypt', 'decrypt', 'encrypt_mem', 'decrypt_mem', 35 | 'sign', 'verify', 'encrypt_and_sign', 'verify_and_decrypt' 36 | ] 37 | 38 | 39 | class CryptException(Exception): 40 | """ 41 | Base class of all exceptions defined in this module. 42 | """ 43 | pass 44 | 45 | 46 | if os.name == 'nt': 47 | PAGESIGN_DIR = os.path.join(os.environ['LOCALAPPDATA'], 'pagesign') 48 | else: 49 | PAGESIGN_DIR = os.path.expanduser('~/.pagesign') 50 | 51 | CREATED_PATTERN = re.compile('# created: (.*)', re.I) 52 | APK_PATTERN = re.compile('# public key: (.*)', re.I) 53 | ASK_PATTERN = re.compile(r'AGE-SECRET-KEY-.*') 54 | MPI_PATTERN = re.compile(r'minisign public key (\S+)') 55 | 56 | if not os.path.exists(PAGESIGN_DIR): # pragma: no cover 57 | os.makedirs(PAGESIGN_DIR) 58 | 59 | if not os.path.isdir(PAGESIGN_DIR): # pragma: no cover 60 | raise ValueError('%s exists but is not a directory.' % PAGESIGN_DIR) 61 | 62 | os.chmod(PAGESIGN_DIR, 0o700) 63 | 64 | 65 | def _load_keys(): 66 | result = {} 67 | p = os.path.join(PAGESIGN_DIR, 'keys') 68 | if os.path.exists(p): # pragma: no branch 69 | with open(p, encoding='utf-8') as f: 70 | result = json.load(f) 71 | return result 72 | 73 | 74 | def _save_keys(keys): 75 | p = os.path.join(PAGESIGN_DIR, 'keys') 76 | with open(p, 'w', encoding='utf-8') as f: 77 | json.dump(keys, f, indent=2, sort_keys=True) 78 | os.chmod(p, 0o600) 79 | 80 | 81 | KEYS = _load_keys() 82 | 83 | PUBLIC_ATTRS = ('created', 'crypt_public', 'sign_public', 'sign_id') 84 | 85 | ATTRS = PUBLIC_ATTRS + ('crypt_secret', 'sign_secret', 'sign_pass') 86 | 87 | 88 | def clear_identities(): 89 | """ 90 | Clear all identities saved locally. 91 | """ 92 | if len(KEYS): 93 | KEYS.clear() 94 | _save_keys(KEYS) 95 | 96 | 97 | def remove_identities(*args): 98 | """ 99 | Remove the identities stored locally whose names are in *args*. Names are 100 | case-sensitive. 101 | 102 | Args: 103 | args (list[str]): The list of identities to remove. 104 | """ 105 | changed = False 106 | for name in args: 107 | if name in KEYS: 108 | del KEYS[name] 109 | changed = True 110 | if changed: 111 | _save_keys(KEYS) 112 | 113 | 114 | def list_identities(): 115 | """ 116 | Return an iterator over the locally stored identities, as name-value 2-tuples. 117 | """ 118 | return KEYS.items() 119 | 120 | 121 | def _make_password(length): 122 | return base64.b64encode(os.urandom(length)).decode('ascii') 123 | 124 | 125 | def _read_out(stream, result, key='stdout'): 126 | data = b'' 127 | while True: 128 | c = stream.read1(100) 129 | if not c: 130 | break 131 | data += c 132 | result[key] = data 133 | 134 | 135 | def _run_command(cmd, wd, err_reader=None, decode=True): 136 | # print('Running: %s' % (cmd if isinstance(cmd, str) else ' '.join(cmd))) 137 | # if cmd[0] == 'age': import pdb; pdb.set_trace() 138 | if not isinstance(cmd, list): 139 | cmd = cmd.split() 140 | logger.debug('Running: %s' % cmd) 141 | kwargs = {'cwd': wd, 'stdout': subprocess.PIPE, 'stderr': subprocess.PIPE} 142 | if err_reader: 143 | kwargs['stdin'] = subprocess.PIPE 144 | p = subprocess.Popen(cmd, **kwargs) 145 | if err_reader is None: 146 | stdout, stderr = p.communicate() 147 | else: 148 | data = {} 149 | rout = threading.Thread(target=_read_out, args=(p.stdout, data)) 150 | rout.daemon = True 151 | rout.start() 152 | 153 | rerr = threading.Thread(target=err_reader, 154 | args=(p.stderr, p.stdin, data)) 155 | rerr.daemon = True 156 | rerr.start() 157 | 158 | rout.join() 159 | rerr.join() 160 | 161 | p.wait() 162 | 163 | stdout = data['stdout'] 164 | stderr = data['stderr'] 165 | 166 | p.stdout.close() 167 | p.stderr.close() 168 | 169 | if p.returncode == 0: 170 | if decode: 171 | stdout = stdout.decode('utf-8') 172 | stderr = stderr.decode('utf-8') 173 | return stdout, stderr 174 | else: # pragma: no cover 175 | raise subprocess.CalledProcessError(p.returncode, 176 | p.args, 177 | output=stdout, 178 | stderr=stderr) 179 | 180 | 181 | def _get_work_file(**kwargs): 182 | fd, result = tempfile.mkstemp(**kwargs) 183 | os.close(fd) 184 | return result 185 | 186 | 187 | def _shred(path, delete=True): 188 | size = os.stat(path).st_size 189 | passes = 2 190 | with open(path, 'wb') as f: 191 | for i in range(passes): 192 | if i > 0: 193 | f.seek(0) 194 | f.write(os.urandom(size)) 195 | if delete: 196 | os.remove(path) 197 | 198 | 199 | class Identity: 200 | """ 201 | This class represents both remote identities (used for encryption and verification 202 | only) and local identities (used for all functions - encryption, decryption, 203 | signing and verification). 204 | """ 205 | encoding = 'utf-8' 206 | 207 | def __init__(self, name=None): 208 | """ 209 | Either retrieve an existing identity named *name*, or, if not specified, create 210 | a new local identity which can later be named using its :meth:`save` method. 211 | Names are case-sensitive. 212 | """ 213 | if name: 214 | if name in KEYS: 215 | self.__dict__.update(KEYS[name]) 216 | else: # pragma: no cover 217 | raise ValueError('No such identity: %r' % name) 218 | else: 219 | # Generate a new identity 220 | wd = tempfile.mkdtemp(dir=PAGESIGN_DIR, prefix='work-') 221 | try: 222 | p = os.path.join(wd, 'age-key') 223 | cmd = 'age-keygen -o %s' % p 224 | try: 225 | _run_command(cmd, wd) 226 | self._parse_age_file(p) 227 | for name in ('created', 'crypt_public', 'crypt_secret'): 228 | getattr(self, name) # ensure the attribute is there 229 | except Exception as e: # pragma: no cover 230 | raise CryptException( 231 | 'Identity creation failed (crypt)') from e 232 | finally: 233 | # the whole working directory will get removed, so pass False 234 | # to _shred as we don't need to delete the file now 235 | # (same logic applies to _shred calls below) 236 | _shred(p, False) 237 | sfn = _get_work_file(prefix='msk-', dir=wd) 238 | pfn = _get_work_file(prefix='mpk-', dir=wd) 239 | self.sign_pass = _make_password(12) 240 | cmd = 'minisign -fG -p %s -s %s' % (pfn, sfn) 241 | try: 242 | _run_command(cmd, wd, self._read_minisign_gen_err) 243 | self._parse_minisign_file(pfn) 244 | for name in ('sign_id', 'sign_public'): 245 | getattr(self, name) # ensure the attribute is there 246 | self.sign_secret = Path(sfn).read_text(self.encoding) 247 | except Exception as e: # pragma: no cover 248 | raise CryptException( 249 | 'Identity creation failed (sign)') from e 250 | finally: 251 | _shred(pfn, False) 252 | _shred(sfn, False) 253 | finally: 254 | shutil.rmtree(wd) 255 | 256 | for attr in ATTRS: 257 | assert hasattr(self, attr) 258 | 259 | def _parse_age_file(self, fn): 260 | with open(fn, encoding=self.encoding) as f: 261 | lines = f.read().splitlines() 262 | for line in lines: 263 | m = CREATED_PATTERN.match(line) 264 | if m: 265 | self.created = m.groups()[0] 266 | continue 267 | m = APK_PATTERN.match(line) 268 | if m: 269 | self.crypt_public = m.groups()[0] 270 | continue 271 | m = ASK_PATTERN.match(line) 272 | assert m, 'Secret key line not seen' 273 | self.crypt_secret = line 274 | 275 | def _parse_minisign_file(self, fn): 276 | with open(fn, encoding=self.encoding) as f: 277 | lines = f.read().splitlines() 278 | for line in lines: 279 | m = MPI_PATTERN.search(line) 280 | if m: 281 | self.sign_id = m.groups()[0] 282 | else: 283 | self.sign_public = line 284 | 285 | def save(self, name): 286 | """ 287 | Save this instance with the specified *name*, which cannot be blank or 288 | ``None``. Names are case-sensitive. 289 | """ 290 | if not name or not isinstance(name, str): # pragma: no cover 291 | raise ValueError('Invalid name: %r' % name) 292 | d = dict(self.__dict__) 293 | # might need to remove some attrs from d here ... 294 | KEYS[name] = d 295 | _save_keys(KEYS) 296 | 297 | def _read_minisign_gen_err(self, stream, stdin, result): 298 | data = b'' 299 | pwd = (self.sign_pass + os.linesep).encode('ascii') 300 | pwd_written = 0 301 | sep = os.linesep.encode('ascii') 302 | prompt1 = b'Password: ' 303 | prompt2 = prompt1 + sep + b'Password (one more time): ' 304 | prompts = (prompt1, prompt2) 305 | while True: 306 | c = stream.read1(100) 307 | data += c 308 | # print('err: %s' % data) 309 | if data in prompts: 310 | stdin.write(pwd) 311 | stdin.flush() 312 | pwd_written += 1 313 | # print('Wrote pwd') 314 | if pwd_written == 2: 315 | stdin.close() 316 | break 317 | result['stderr'] = data 318 | 319 | def _read_minisign_sign_err(self, stream, stdin, result): 320 | data = b'' 321 | pwd = (self.sign_pass + os.linesep).encode('ascii') 322 | while True: 323 | c = stream.read(1) 324 | data += c 325 | # print('err: %s' % data) 326 | if data == b'Password: ': 327 | stdin.write(pwd) 328 | stdin.close() 329 | break 330 | result['stderr'] = data 331 | 332 | def export(self): 333 | """ 334 | Export this instance. Only public attributes are preserved in the export 335 | --- it is meant for sending to someone securely. 336 | 337 | Returns: 338 | dict: A dictionary containing the exportable items of the instance. 339 | """ 340 | d = dict(self.__dict__) 341 | for k in self.__dict__: 342 | if '_secret' in k or '_pass' in k: 343 | del d[k] 344 | return d 345 | 346 | @classmethod 347 | def imported(cls, d, name): 348 | """ 349 | Return a remote identity instance created from *d* and with local name *name*. 350 | 351 | Args: 352 | d (dict): A dictionary from some external source. It must contain the public 353 | attributes *created*, *crypt_public*, *sign_public* and 354 | *sign_id* (which will be present in dictionaries created 355 | using the :meth:`export` method) 356 | 357 | name (str): A name against which to save the imported information. 358 | Note that names are case-sensitive. 359 | 360 | Returns: 361 | Identity: The saved identity constructed from *d*. 362 | """ 363 | result = object.__new__(cls) 364 | for k in PUBLIC_ATTRS: 365 | try: 366 | setattr(result, k, d[k]) 367 | except KeyError: # pragma: no cover 368 | logger.warning('Attribute absent: %s', k) 369 | result.save(name) 370 | return result 371 | 372 | 373 | def _get_encryption_command(recipients, armor): 374 | if not recipients: # pragma: no cover 375 | raise ValueError('At least one recipient needs to be specified.') 376 | result = ['age', '-e'] 377 | if armor: 378 | result.append('-a') 379 | if isinstance(recipients, str): 380 | recipients = [recipients] 381 | if not isinstance(recipients, (list, tuple)): # pragma: no cover 382 | raise ValueError('invalid recipients: %s' % recipients) 383 | for r in recipients: 384 | if r not in KEYS: # pragma: no cover 385 | raise ValueError('No such recipient: %s' % r) 386 | info = KEYS[r] 387 | result.extend(['-r', info['crypt_public']]) 388 | return result 389 | 390 | 391 | def encrypt(path, recipients, outpath=None, armor=False): 392 | """ 393 | Encrypt the file at *path* for identities whose names are in *recipients* and 394 | save the encrypted data in *outpath*. The output data is ASCII-armored if *armor* 395 | is true, else it is binary. 396 | 397 | Args: 398 | path (str): The path to the data to be encrypted. 399 | 400 | recipients (str|list[str]): The name(s) of the identities of the recipient(s) 401 | of the data. 402 | 403 | outpath (str): The path to which the encrypted data should be written. 404 | If not specified, it will be set to *path* with 405 | ``'.age'`` appended. 406 | 407 | armor (bool): Whether the output is to be ASCII-armored. 408 | 409 | Returns: 410 | str: The value of *outpath* is returned. 411 | """ 412 | if not os.path.isfile(path): # pragma: no cover 413 | raise ValueError('No such file: %s' % path) 414 | if outpath is None: 415 | outpath = '%s.age' % path 416 | else: 417 | d = os.path.dirname(outpath) 418 | if not os.path.exists(d): # pragma: no cover 419 | os.makedirs(d) 420 | elif not os.path.isdir(d): # pragma: no cover 421 | raise ValueError('Not a directory: %s' % d) 422 | # if dir, assume writeable, for now 423 | 424 | cmd = _get_encryption_command(recipients, armor) 425 | cmd.extend(['-o', outpath]) 426 | cmd.append(path) 427 | try: 428 | _run_command(cmd, os.getcwd()) 429 | return outpath 430 | except subprocess.CalledProcessError as e: # pragma: no cover 431 | raise CryptException('Encryption failed') from e 432 | 433 | 434 | def _data_writer(data, stream, stdin, result): 435 | stdin.write(data) 436 | stdin.close() 437 | _read_out(stream, result, 'stderr') 438 | 439 | 440 | def encrypt_mem(data, recipients, armor=False): 441 | """ 442 | Encrypt the in-memory *data* for identities whose names are in *recipients*. The 443 | output data is ASCII-armored if *armor* is true, else it is binary. The encrypted 444 | data is returned as bytes. 445 | 446 | Args: 447 | data (str|bytes): The data to be encrypted. 448 | 449 | recipients (str|list[str]): The name(s) of the identities of the 450 | recipient(s) of the data. 451 | 452 | armor (bool): Whether the output is to be ASCII-armored. 453 | 454 | Returns: 455 | bytes: The encrypted data. 456 | """ 457 | cmd = _get_encryption_command(recipients, armor) 458 | if isinstance(data, str): 459 | data = data.encode('utf-8') 460 | if not isinstance(data, bytes): # pragma: no cover 461 | raise TypeError('invalid data: %s' % data) 462 | err_reader = functools.partial(_data_writer, data) 463 | try: 464 | stdout, stderr = _run_command(cmd, os.getcwd(), err_reader, False) 465 | return stdout 466 | except subprocess.CalledProcessError as e: # pragma: no cover 467 | raise CryptException('Encryption failed') from e 468 | 469 | 470 | def _get_decryption_command(identities): 471 | if not identities: # pragma: no cover 472 | raise ValueError('At least one identity needs to be specified.') 473 | cmd = ['age', '-d'] 474 | if isinstance(identities, str): 475 | identities = [identities] 476 | if not isinstance(identities, (list, tuple)): # pragma: no cover 477 | raise ValueError('invalid identities: %s' % identities) 478 | fn = _get_work_file(dir=PAGESIGN_DIR, prefix='ident-') 479 | ident_values = [] 480 | for ident in identities: 481 | if ident not in KEYS: # pragma: no cover 482 | raise ValueError('No such identity: %s' % ident) 483 | ident_values.append(KEYS[ident]['crypt_secret']) 484 | with open(fn, 'w', encoding='utf-8') as f: 485 | f.write('\n'.join(ident_values)) 486 | cmd.extend(['-i', fn]) 487 | return cmd, fn 488 | 489 | 490 | def decrypt(path, identities, outpath=None): 491 | """ 492 | Decrypt the data at *path* which is intended for recipients named in *identities* 493 | and save the decrypted data at *outpath*. 494 | 495 | Args: 496 | path (str): The path to the data to be decrypted. 497 | 498 | identities (str|list[str]): The name(s) of the recipient(s) of the data. 499 | 500 | outpath (str): The path to which the decrypted data should be written. 501 | If not specified and *path* ends with ``'.age'``, then 502 | *outpath* will be set to *path* with that suffix 503 | stripped. Otherwise, it will be set to *path* with 504 | ``'.dec'`` appended. 505 | 506 | Returns: 507 | str: The value of *outpath* is returned. 508 | """ 509 | if not os.path.isfile(path): # pragma: no cover 510 | raise ValueError('No such file: %s' % path) 511 | if outpath is None: 512 | if path.endswith('.age'): 513 | outpath = path[:-4] 514 | else: 515 | outpath = '%s.dec' % path 516 | else: 517 | d = os.path.dirname(outpath) 518 | if not os.path.exists(d): # pragma: no cover 519 | os.makedirs(d) 520 | elif not os.path.isdir(d): # pragma: no cover 521 | raise ValueError('Not a directory: %s' % d) 522 | # if dir, assume writeable, for now 523 | 524 | cmd, fn = _get_decryption_command(identities) 525 | # import pdb; pdb.set_trace() 526 | try: 527 | cmd.extend(['-o', outpath]) 528 | cmd.append(path) 529 | _run_command(cmd, os.getcwd()) 530 | return outpath 531 | except subprocess.CalledProcessError as e: # pragma: no cover 532 | raise CryptException('Decryption failed') from e 533 | finally: 534 | _shred(fn) 535 | 536 | 537 | def decrypt_mem(data, identities): 538 | """ 539 | Decrypt the in-memory *data* for recipients whose names are in *identities*. 540 | 541 | Args: 542 | data (str|bytes): The data to decrypt. 543 | 544 | identities (str|list[str]): The name(s) of the identities of the 545 | recipient(s) of the data. 546 | 547 | Returns: 548 | bytes: The decrypted data. 549 | """ 550 | cmd, fn = _get_decryption_command(identities) 551 | if isinstance(data, str): # pragma: no cover 552 | data = data.encode('utf-8') 553 | if not isinstance(data, bytes): # pragma: no cover 554 | raise TypeError('invalid data: %s' % data) 555 | err_reader = functools.partial(_data_writer, data) 556 | try: 557 | stdout, stderr = _run_command(cmd, os.getcwd(), err_reader, False) 558 | return stdout 559 | except subprocess.CalledProcessError as e: # pragma: no cover 560 | raise CryptException('Decryption failed') from e 561 | finally: 562 | _shred(fn) 563 | 564 | 565 | def sign(path, identity, outpath=None): 566 | """ 567 | Sign the data at *path* with the named *identity* and save the signature in 568 | *outpath*. 569 | 570 | Args: 571 | path (str): The path to the data to be signed. 572 | 573 | identity (str): The name of the signer's identity. 574 | 575 | outpath (str): The path to which the signature is to be written. If not 576 | specified, *outpath* is set to *path* with ``'.sig'`` 577 | appended. 578 | 579 | Returns: 580 | str: The value of *outpath* is returned. 581 | """ 582 | if not identity: # pragma: no cover 583 | raise ValueError('An identity needs to be specified.') 584 | if identity not in KEYS: # pragma: no cover 585 | raise ValueError('No such identity: %s' % identity) 586 | ident = Identity(identity) 587 | if not os.path.isfile(path): # pragma: no cover 588 | raise ValueError('No such file: %s' % path) 589 | if outpath is None: 590 | outpath = '%s.sig' % path 591 | else: 592 | d = os.path.dirname(outpath) 593 | if not os.path.exists(d): # pragma: no cover 594 | os.makedirs(d) 595 | elif not os.path.isdir(d): # pragma: no cover 596 | raise ValueError('Not a directory: %s' % d) 597 | # if dir, assume writeable, for now 598 | 599 | fd, fn = tempfile.mkstemp(dir=PAGESIGN_DIR, prefix='seckey-') 600 | os.write(fd, (KEYS[identity]['sign_secret'] + os.linesep).encode('ascii')) 601 | os.close(fd) 602 | try: 603 | cmd = ['minisign', '-S', '-x', outpath, '-s', fn, '-m', path] 604 | _run_command(cmd, os.getcwd(), ident._read_minisign_sign_err) 605 | except Exception as e: # pragma: no cover 606 | raise CryptException('Signing failed') from e 607 | finally: 608 | _shred(fn) 609 | return outpath 610 | 611 | 612 | def verify(path, identity, sigpath=None): 613 | """ 614 | Verify that the data at *path* was signed with the identity named *identity*, 615 | where the signature is at *sigpath*. If verification fails, an exception is 616 | raised, otherwise this function returns `None`. 617 | 618 | Args: 619 | path (str): The path to the data to be verified. 620 | 621 | identity (str): The name of the signer's identity. 622 | 623 | sigpath (str): The path where the signature is stored. If not specified, 624 | *sigpath* is set to *path* with `'.sig'` appended. 625 | """ 626 | if not identity: # pragma: no cover 627 | raise ValueError('An identity needs to be specified.') 628 | if identity not in KEYS: # pragma: no cover 629 | raise ValueError('No such identity: %s' % identity) 630 | ident = Identity(identity) 631 | if not os.path.isfile(path): # pragma: no cover 632 | raise ValueError('No such file: %s' % path) 633 | if sigpath is None: 634 | sigpath = '%s.sig' % path 635 | if not os.path.isfile(sigpath): # pragma: no cover 636 | raise ValueError('No such file: %s' % sigpath) 637 | cmd = [ 638 | 'minisign', '-V', '-x', sigpath, '-P', ident.sign_public, '-m', path 639 | ] 640 | # import pdb; pdb.set_trace() 641 | try: 642 | _run_command(cmd, os.getcwd()) 643 | except subprocess.CalledProcessError as e: # pragma: no cover 644 | raise CryptException('Verification failed') from e 645 | 646 | 647 | def _get_b64(path): 648 | with open(path, 'rb') as f: 649 | return base64.b64encode(f.read()).decode('ascii') 650 | 651 | 652 | def encrypt_and_sign(path, 653 | recipients, 654 | signer, 655 | armor=False, 656 | outpath=None, 657 | sigpath=None): 658 | """ 659 | Encrypt the data at *path* for identities named in *recipients* and sign it with 660 | the identity named by *signer*. Write the encrypted data to *outpath* and 661 | the signature to *sigpath*. 662 | 663 | Note that you'll need to call :func:`verify_and_decrypt` to reverse this process. 664 | 665 | Args: 666 | path (str): The path to the data to be decrypted. 667 | 668 | recipients (str|list[str]): The name(s) of the identities of the 669 | recipient(s) of the encrypted data. 670 | 671 | signer (str): The name of the signer identity. 672 | 673 | armor (bool): If `True`, use ASCII armor for the encrypted data, else 674 | save it as binary. 675 | 676 | outpath (str): The output path to which the encrypted data should be 677 | written, If not specified, it will be set to *path* 678 | with ``'.age'`` appended. 679 | 680 | sigpath (str): The path to which the signature should be written. If not 681 | specified, *sigpath* is set to *outpath* with ``'.sig'`` 682 | appended. 683 | 684 | Returns: 685 | tuple(str, str): A tuple of *outpath* and *sigpath* is returned. 686 | """ 687 | if not recipients or not signer: # pragma: no cover 688 | raise ValueError( 689 | 'At least one recipient (and one signer) needs to be specified.') 690 | if not os.path.isfile(path): # pragma: no cover 691 | raise ValueError('No such file: %s' % path) 692 | naive = False 693 | if naive: # pragma: no cover 694 | outpath = encrypt(path, recipients, outpath=outpath, armor=armor) 695 | sigpath = sign(outpath, signer, outpath=sigpath) 696 | return outpath, sigpath 697 | else: 698 | # Use a sign/encrypt/sign strategy: 699 | # 1. Sign the plaintext. 700 | # 2. Construct a JSON of the base64-encoded plaintext and signature. 701 | # 3. Encrypt that. 702 | # 4. Hash all the recipient public keys into a list. 703 | # 5. Construct a JSON of the encrypted data and recipient hashes. 704 | # 6. Sign that. 705 | fn = _get_work_file(dir=PAGESIGN_DIR, prefix='sig-') 706 | sigpath = sign(path, signer, fn) 707 | inner = {'plaintext': _get_b64(path), 'signature': _get_b64(sigpath)} 708 | os.remove(sigpath) 709 | data = json.dumps(inner).encode('ascii') 710 | encrypted = encrypt_mem(data, recipients, armor) 711 | if not armor: 712 | encrypted = base64.b64encode(encrypted) 713 | if isinstance(recipients, str): 714 | recipients = [recipients] 715 | # if we encrypted OK, there can't have been problems with the recipients 716 | hashes = [] 717 | for r in recipients: 718 | info = KEYS[r] 719 | pk = info['crypt_public'].encode('ascii') 720 | hashes.append(hashlib.sha256(pk).hexdigest()) 721 | outer = { 722 | 'encrypted': encrypted.decode('ascii'), 723 | 'armored': armor, 724 | 'recipients': hashes 725 | } 726 | data = json.dumps(outer).encode('ascii') 727 | outpath = _get_work_file(dir=PAGESIGN_DIR, prefix='message-') 728 | Path(outpath).write_bytes(data) 729 | sigpath = sign(outpath, signer) 730 | return outpath, sigpath 731 | 732 | 733 | def verify_and_decrypt(path, recipients, signer, outpath=None, sigpath=None): 734 | """ 735 | Verify the encrypted and signed data at *path* as having been signed by the 736 | identity named by *signer* and intended for identities named in *recipients*. 737 | The signature for *path* is in *sigpath*. If not specified, it will be set to 738 | *path* with ``'.sig'`` appended. If verification or decryption fails, an exception 739 | will be raised. Otherwise, the decrypted data will be stored at *outpath*. If 740 | not specified, it will be set to *path* with the suffix stripped (if it ends in 741 | ``'.age'``) or with ``'.dec'`` appended. 742 | 743 | The function returns *outpath*. 744 | 745 | Note that the file inputs to this function should have been created using 746 | :func:`encrypt_and_sign`. 747 | 748 | Args: 749 | path (str): The path to the encrypted and signed data. 750 | 751 | recipients (str|list[str]): The name(s) of the recipient(s) of the encrypted data. 752 | 753 | signer (str): The name of the signer identity. 754 | 755 | outpath (str): The output path to which the decrypted data should be written, 756 | 757 | sigpath (str): The path in which the signature is to be found. 758 | 759 | Returns: 760 | str: The value of outpath is returned. 761 | """ 762 | if not signer or not recipients: # pragma: no cover 763 | raise ValueError( 764 | 'At least one recipient (and one signer) needs to be specified.') 765 | if not os.path.isfile(path): # pragma: no cover 766 | raise ValueError('No such file: %s' % path) 767 | if sigpath is None: # pragma: no cover 768 | sigpath = path + '.sig' 769 | if not os.path.exists(sigpath): # pragma: no cover 770 | raise ValueError('no such file: %s' % sigpath) 771 | verify(path, signer, sigpath) 772 | naive = False 773 | if naive: # pragma: no cover 774 | return decrypt(path, recipients, outpath) 775 | else: 776 | with open(path, 'r', encoding='ascii') as f: 777 | outer = json.load(f) 778 | encrypted = outer['encrypted'].encode('ascii') 779 | if not outer['armored']: 780 | encrypted = base64.b64decode(encrypted) 781 | hashes = set(outer['recipients']) 782 | if isinstance(recipients, str): 783 | recipients = [recipients] 784 | for r in recipients: 785 | if r not in KEYS: # pragma: no cover 786 | raise ValueError('No such recipient: %s' % r) 787 | info = KEYS[r] 788 | pk = info['crypt_public'].encode('ascii') 789 | h = hashlib.sha256(pk).hexdigest() 790 | if h not in hashes: # pragma: no cover 791 | raise ValueError('Not a valid recipient: %s' % r) 792 | decrypted = decrypt_mem(encrypted, recipients).decode('ascii') 793 | inner = json.loads(decrypted) 794 | fd, outpath = tempfile.mkstemp(dir=PAGESIGN_DIR, prefix='msg-') 795 | os.write(fd, base64.b64decode(inner['plaintext'].encode('ascii'))) 796 | os.close(fd) 797 | sigpath = outpath + '.sig' 798 | Path(sigpath).write_bytes( 799 | base64.b64decode(inner['signature'].encode('ascii'))) 800 | verify(outpath, signer, sigpath) 801 | os.remove(sigpath) 802 | return outpath 803 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools >= 44", 4 | "wheel >= 0.29.0", 5 | ] 6 | build-backend = 'setuptools.build_meta' 7 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = pagesign 3 | version = attr: pagesign.__version__ 4 | description = A wrapper for the modern encryption and signing tools age and minisign 5 | long_description = This module allows easy access to key management, encryption and 6 | signature functionality using age and minisign from Python programs. It is intended 7 | for use with Python 3.6 or greater. 8 | 9 | Releases are normally signed using minisign, and files in a release can be verified 10 | by downloading a file and its signature into the same directory and running 11 | 12 | minisign -Vm -P RWTobFh2+FfcAHXgOfx6voa7Rvm5C0CwPlQufQzIEKEZtfFzewaK/KqE 13 | 14 | where the -P argument is the public counterpart of the private signing key. 15 | 16 | You should be able to download release archives and signatures from 17 | 18 | https://github.com/vsajip/pagesign/releases/ 19 | 20 | The archives should be the same as those uploaded to PyPI. 21 | url = https://github.com/vsajip/pagesign 22 | author = Vinay Sajip 23 | author_email = vinay_sajip@yahoo.co.uk 24 | maintainer = Vinay Sajip 25 | maintainer_email = vinay_sajip@yahoo.co.uk 26 | license = BSD 27 | license_file = LICENSE.txt 28 | classifiers = 29 | Development Status :: 3 - Alpha 30 | Intended Audience :: Developers 31 | License :: OSI Approved :: BSD License 32 | Programming Language :: Python 33 | Programming Language :: Python :: 3 34 | Programming Language :: Python :: 3.6 35 | Programming Language :: Python :: 3.7 36 | Programming Language :: Python :: 3.8 37 | Programming Language :: Python :: 3.9 38 | Programming Language :: Python :: 3.10 39 | Programming Language :: Python :: 3.11 40 | Programming Language :: Python :: 3.12 41 | Programming Language :: Python :: 3.13 42 | Operating System :: OS Independent 43 | Topic :: Software Development :: Libraries :: Python Modules" 44 | project_urls = 45 | Documentation = https://docs.red-dove.com/pagesign/ 46 | Source = https://github.com/vsajip/pagesign 47 | Tracker = https://github.com/vsajip/pagesign/issues 48 | keywords = cryptography,encryption,decryption.signing,verification,age,minisign 49 | platforms = any 50 | 51 | [options] 52 | py_modules = pagesign 53 | 54 | [bdist_wheel] 55 | universal = 1 56 | -------------------------------------------------------------------------------- /test_pagesign.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Copyright (C) 2021-2022 Red Dove Consultants Limited. MIT Licensed. 5 | # 6 | import json 7 | import logging 8 | import os 9 | from pathlib import Path 10 | import shutil 11 | import sys 12 | import tempfile 13 | import unittest 14 | from unittest.mock import patch 15 | 16 | from pagesign import (Identity, CryptException, encrypt, encrypt_mem, decrypt, 17 | decrypt_mem, sign, verify, encrypt_and_sign, 18 | verify_and_decrypt, remove_identities, clear_identities, 19 | list_identities, _get_work_file) 20 | 21 | DEBUGGING = 'PY_DEBUG' in os.environ 22 | 23 | logger = logging.getLogger(__name__) 24 | 25 | 26 | class BaseTest(unittest.TestCase): 27 | HSEP = '=' * 60 28 | FSEP = '-' * 60 29 | 30 | def setUp(self): 31 | ident = self.id().rsplit('.', 1)[-1] 32 | logger.debug(self.HSEP) 33 | logger.debug('%s starting ...', ident) 34 | logger.debug(self.HSEP) 35 | 36 | def tearDown(self): 37 | ident = self.id().rsplit('.', 1)[-1] 38 | logger.debug(self.FSEP) 39 | logger.debug('%s finished.', ident) 40 | 41 | 42 | class BasicTest(BaseTest): 43 | 44 | def test_clearing_creating_listing_and_removal(self): 45 | clear_identities() 46 | d = dict(list_identities()) 47 | self.assertEqual(len(d), 0) 48 | clear_identities() # call again when already cleared (coverage) 49 | d = dict(list_identities()) 50 | self.assertEqual(len(d), 0) 51 | names = {'bob', 'carol', 'ted', 'alice'} 52 | for name in names: 53 | identity = Identity() 54 | identity.save(name) 55 | d = dict(list_identities()) 56 | self.assertEqual(set(d), names) 57 | remove_identities('bob', 'alice') 58 | d = dict(list_identities()) 59 | self.assertEqual(set(d), {'ted', 'carol'}) 60 | remove_identities('foo') # non-existent identity 61 | d = dict(list_identities()) 62 | self.assertEqual(set(d), {'ted', 'carol'}) 63 | 64 | def test_export(self): 65 | for name in ('foo', 'bar'): 66 | identity = Identity() 67 | identity.save(name) 68 | d = identity.export() 69 | for k in d: 70 | self.assertNotIn('_secret', k) 71 | self.assertNotIn('_pass', k) 72 | 73 | def test_import(self): 74 | identity = Identity() 75 | identity.save('foo') 76 | exported = identity.export() 77 | imported = Identity.imported(exported, 'bar') 78 | self.assertEqual(exported, imported.export()) 79 | 80 | def test_encryption_and_signing_separately(self): 81 | for name in ('alice', 'bob'): 82 | identity = Identity() 83 | identity.save(name) 84 | 85 | for armor in (False, True): 86 | fd, fn = tempfile.mkstemp(prefix='test-pagesign-') 87 | self.addCleanup(os.remove, fn) 88 | data = b'Hello, world!' 89 | os.write(fd, data) 90 | os.close(fd) 91 | encrypted = encrypt(fn, 'bob', armor=armor) 92 | self.addCleanup(os.remove, encrypted) 93 | # Now sign it 94 | signed = sign(encrypted, 'alice') 95 | self.addCleanup(os.remove, signed) 96 | # Now verify it 97 | verify(encrypted, 'alice', signed) 98 | fn = _get_work_file(prefix='test-pagesign-') 99 | self.addCleanup(os.remove, fn) 100 | decrypted = decrypt(encrypted, 'bob', fn) 101 | ddata = Path(decrypted).read_bytes() 102 | self.assertEqual(data, ddata) 103 | with self.assertRaises(CryptException) as ec: 104 | verify(encrypted, 'bob', signed) 105 | self.assertEqual(str(ec.exception), 'Verification failed') 106 | 107 | def test_encryption_and_signing_together(self): 108 | for name in ('alice', 'bob'): 109 | identity = Identity() 110 | identity.save(name) 111 | 112 | for armor in (False, True): 113 | fd, fn = tempfile.mkstemp(prefix='test-pagesign-') 114 | self.addCleanup(os.remove, fn) 115 | data = b'Hello, world!' 116 | os.write(fd, data) 117 | os.close(fd) 118 | outpath, sigpath = encrypt_and_sign(fn, 119 | 'bob', 120 | 'alice', 121 | armor=armor) 122 | # self.assertEqual(outpath, fn + '.age') 123 | self.assertEqual(sigpath, outpath + '.sig') 124 | self.addCleanup(os.remove, outpath) 125 | self.addCleanup(os.remove, sigpath) 126 | verify(outpath, 'alice', sigpath) 127 | # Repeat call using recipient as list 128 | outpath, sigpath = encrypt_and_sign(fn, ['bob'], 129 | 'alice', 130 | armor=armor) 131 | # self.assertEqual(outpath, fn + '.age') 132 | self.assertEqual(sigpath, outpath + '.sig') 133 | self.addCleanup(os.remove, outpath) 134 | self.addCleanup(os.remove, sigpath) 135 | verify(outpath, 'alice', sigpath) 136 | 137 | def test_verifying_and_decrypting_together(self): 138 | for name in ('alice', 'bob'): 139 | identity = Identity() 140 | identity.save(name) 141 | 142 | for armor in (False, True): 143 | fd, fn = tempfile.mkstemp(prefix='test-pagesign-') 144 | self.addCleanup(os.remove, fn) 145 | data = b'Hello, world!' 146 | os.write(fd, data) 147 | os.close(fd) 148 | outpath, sigpath = encrypt_and_sign(fn, 149 | 'bob', 150 | 'alice', 151 | armor=armor) 152 | self.addCleanup(os.remove, outpath) 153 | self.addCleanup(os.remove, sigpath) 154 | fn = _get_work_file(prefix='test-pagesign-') 155 | self.addCleanup(os.remove, fn) 156 | decrypted = verify_and_decrypt(outpath, 'bob', 'alice', fn, 157 | sigpath) 158 | ddata = Path(decrypted).read_bytes() 159 | self.assertEqual(data, ddata) 160 | os.remove(decrypted) 161 | # Repeat call with recipient as list 162 | decrypted = verify_and_decrypt(outpath, ['bob'], 'alice', fn, 163 | sigpath) 164 | ddata = Path(decrypted).read_bytes() 165 | self.assertEqual(data, ddata) 166 | os.remove(decrypted) 167 | 168 | def test_encryption_in_memory(self): 169 | for name in ('alice', 'bob'): 170 | identity = Identity() 171 | identity.save(name) 172 | 173 | data = 'Hello, world!' 174 | for armor in (False, True): 175 | encrypted = encrypt_mem(data, 'bob', armor=armor) 176 | decrypted = decrypt_mem(encrypted, 'bob') 177 | self.assertEqual(decrypted, data.encode('utf-8')) 178 | 179 | def test_multiple_recipients(self): 180 | for name in ('alice', 'bob', 'carol', 'ted'): 181 | identity = Identity() 182 | identity.save(name) 183 | data = b'Hello, world!' 184 | recipients = ['alice', 'carol', 'ted'] 185 | encrypted = encrypt_mem(data, recipients) 186 | for name in recipients: 187 | ddata = decrypt_mem(encrypted, name) 188 | self.assertEqual(data, ddata) 189 | with self.assertRaises(CryptException) as ec: 190 | ddata = decrypt_mem(encrypted, 'bob') 191 | self.assertEqual(str(ec.exception), 'Decryption failed') 192 | 193 | def test_identity_failures(self): 194 | 195 | class Dummy1(Identity): 196 | 197 | def _parse_age_file(self, fn): 198 | pass 199 | 200 | class Dummy2(Identity): 201 | 202 | def _parse_minisign_file(self, fn): 203 | pass 204 | 205 | with self.assertRaises(CryptException) as ec: 206 | Dummy1() 207 | self.assertEqual(str(ec.exception), 'Identity creation failed (crypt)') 208 | 209 | with self.assertRaises(CryptException) as ec: 210 | Dummy2() 211 | self.assertEqual(str(ec.exception), 'Identity creation failed (sign)') 212 | 213 | def test_signing_failure(self): 214 | 215 | def dummy(*args, **kwargs): 216 | raise ValueError() 217 | 218 | identity = Identity() 219 | identity.save('alice') 220 | 221 | fn = _get_work_file(prefix='test-pagesign-') 222 | sfn = _get_work_file(prefix='test-pagesign-sig-') 223 | self.addCleanup(os.remove, fn) 224 | self.addCleanup(os.remove, sfn) 225 | 226 | with self.assertRaises(CryptException) as ec: 227 | with patch('pagesign._run_command', dummy): 228 | sign(fn, 'alice', sfn) 229 | self.assertEqual(str(ec.exception), 'Signing failed') 230 | 231 | def test_default_paths(self): 232 | for name in ('alice', 'bob'): 233 | identity = Identity() 234 | identity.save(name) 235 | 236 | fd, fn = tempfile.mkstemp(prefix='test-pagesign-') 237 | self.addCleanup(os.remove, fn) 238 | data = b'Hello, world!' 239 | os.write(fd, data) 240 | os.close(fd) 241 | 242 | # Encryption / decryption 243 | 244 | # Test with no encrypted outpath 245 | encrypted = encrypt(fn, 'alice') 246 | self.addCleanup(os.remove, encrypted) 247 | self.assertEqual(encrypted, fn + '.age') 248 | decrypted = decrypt(encrypted, 'alice') 249 | self.assertEqual(decrypted, fn) 250 | # Test with specified encrypted outpath 251 | fd, ofn = tempfile.mkstemp(prefix='test-pagesign-') 252 | self.addCleanup(os.remove, ofn) 253 | os.close(fd) 254 | encrypted = encrypt(fn, 'alice', outpath=ofn) 255 | self.assertEqual(encrypted, ofn) 256 | decrypted = decrypt(ofn, 'alice') 257 | self.addCleanup(os.remove, decrypted) 258 | self.assertEqual(decrypted, ofn + '.dec') 259 | 260 | # Signing / verification 261 | 262 | # Test with no signed outpath 263 | signed = sign(fn, 'alice') 264 | self.addCleanup(os.remove, signed) 265 | self.assertEqual(signed, fn + '.sig') 266 | verify(fn, 'alice') 267 | # Test with specified signed outpath 268 | signed = sign(fn, 'alice', outpath=ofn) 269 | self.assertEqual(signed, ofn) 270 | verify(fn, 'alice', sigpath=signed) 271 | 272 | # Encryption and signing / decryption and verification together 273 | 274 | # Using default paths 275 | 276 | outpath, sigpath = encrypt_and_sign(fn, 'bob', 'alice') 277 | self.addCleanup(os.remove, outpath) 278 | self.addCleanup(os.remove, sigpath) 279 | self.assertEqual(sigpath, outpath + '.sig') 280 | dfn = _get_work_file(prefix='test-pagesign-') 281 | self.addCleanup(os.remove, dfn) 282 | decrypted = verify_and_decrypt(outpath, 'bob', 'alice', dfn, sigpath) 283 | ddata = Path(decrypted).read_bytes() 284 | self.assertEqual(data, ddata) 285 | os.remove(decrypted) 286 | 287 | 288 | def main(): 289 | fn = os.path.basename(__file__) 290 | fn = os.path.splitext(fn)[0] 291 | lfn = os.path.expanduser('~/logs/%s.log' % fn) 292 | d = os.path.dirname(lfn) 293 | if not os.path.exists(d): # pragma: no cover 294 | os.makedirs(d) 295 | if os.path.isdir(os.path.dirname(lfn)): # pragma: no branch 296 | logging.basicConfig(level=logging.DEBUG, 297 | filename=lfn, 298 | filemode='w', 299 | format='%(message)s') 300 | # Is there an existing store? 301 | from pagesign import PAGESIGN_DIR 302 | existing = os.path.join(PAGESIGN_DIR, 'keys') 303 | if not os.path.exists(existing): # pragma: no cover 304 | preserved = backup = None 305 | else: 306 | with open(existing, encoding='utf-8') as f: 307 | preserved = json.load(f) 308 | backup = existing + '.bak' 309 | shutil.copy(existing, backup) 310 | try: 311 | unittest.main() 312 | finally: 313 | if preserved: # pragma: no branch 314 | shutil.copy(backup, existing) 315 | os.remove(backup) 316 | 317 | 318 | if __name__ == '__main__': # pragma: no branch 319 | try: 320 | rc = main() 321 | except KeyboardInterrupt: # pragma: no cover 322 | rc = 2 323 | except SystemExit as e: # pragma: no cover 324 | rc = 3 if e.args[0] else 0 325 | except Exception as e: # pragma: no cover 326 | if DEBUGGING: 327 | s = ' %s:' % type(e).__name__ 328 | else: 329 | s = '' 330 | sys.stderr.write('Failed:%s %s\n' % (s, e)) 331 | if DEBUGGING: 332 | import traceback 333 | traceback.print_exc() 334 | rc = 1 335 | sys.exit(rc) 336 | --------------------------------------------------------------------------------