├── .coveragerc ├── .gitattributes ├── .github ├── PULL_REQUEST_TEMPLATE └── workflows │ └── test.yaml ├── .gitignore ├── .testr.conf ├── .travis.yml ├── CONTRIBUTING.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── codecov.yml ├── docs ├── Makefile ├── api-stability.rst ├── certs-dir.rst ├── changelog.rst ├── client_example.py ├── conf.py ├── index.rst ├── pebble-config.json ├── service_example.py └── using.rst ├── pyproject.toml ├── requirements-doc.txt ├── setup.cfg ├── setup.py ├── src ├── integration │ ├── __init__.py │ └── test_client.py └── txacme │ ├── __init__.py │ ├── _version.py │ ├── challenges │ ├── __init__.py │ ├── _http.py │ └── _libcloud.py │ ├── client.py │ ├── errors.py │ ├── interfaces.py │ ├── logging.py │ ├── messages.py │ ├── newsfragments │ ├── .gitignore │ ├── 151-1.feature │ ├── 151-1.removal │ ├── 151.feature │ ├── 151.removal │ ├── 86.bugfix │ └── 86.removal │ ├── service.py │ ├── store.py │ ├── test │ ├── __init__.py │ ├── test_challenges.py │ ├── test_client.py │ ├── test_store.py │ └── test_util.py │ ├── testing.py │ ├── urls.py │ └── util.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = txacme 4 | omit = 5 | src/txacme/_version.py 6 | src/txacme/interfaces.py 7 | 8 | [paths] 9 | source = 10 | src/txacme 11 | .tox/*/lib/python*/site-packages/txacme 12 | .tox/*/site-packages/txacme 13 | 14 | [report] 15 | precision = 2 16 | ignore_errors = True 17 | exclude_lines = 18 | if TYPE_CHECKING 19 | \s*\.\.\.$ 20 | raise NotImplementedError 21 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | src/txacme/_version.py export-subst 2 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE: -------------------------------------------------------------------------------- 1 | ## See CONTRIBUTING.rst for more details. 2 | 3 | ## Contributor Checklist: 4 | 5 | * [ ] The Pull Request description explain the purpose of this PR or provides 6 | a link / reference to a GitHub Issue. 7 | * [ ] Created a newsfragment in src/txacme/newsfragments/. 8 | * [ ] Updated the automated tests. 9 | * [ ] The changes pass minimal style checks. 10 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | # Try to get a short workflow name and a job name that start with Python 2 | # version to make it easier to check the status inside GitHub UI. 3 | name: CI 4 | 5 | on: 6 | push: 7 | branches: [ master ] 8 | pull_request: 9 | branches: [ master ] 10 | 11 | 12 | defaults: 13 | run: 14 | shell: bash 15 | 16 | 17 | jobs: 18 | testing: 19 | runs-on: ubuntu-20.04 20 | name: ${{ matrix.python-version }}-linux 21 | strategy: 22 | fail-fast: false 23 | matrix: 24 | python-version: ["2.7", "3.10", "pypy-3.7"] 25 | env: 26 | # As of April 2021 GHA VM have 2 CPUs - Azure Standard_DS2_v2 27 | # Trial distributed jobs enabled to speed up the CI jobs. 28 | TRIAL_ARGS: "-j 4" 29 | 30 | steps: 31 | - uses: actions/checkout@v2 32 | 33 | - name: Set up Python 34 | uses: actions/setup-python@v2 35 | with: 36 | python-version: ${{ matrix.python-version }} 37 | 38 | - name: Get pip cache dir 39 | id: pip-cache 40 | run: | 41 | echo "::set-output name=dir::$(pip cache dir)" 42 | 43 | - name: pip cache 44 | uses: actions/cache@v2 45 | with: 46 | path: ${{ steps.pip-cache.outputs.dir }} 47 | key: 48 | ${{ runner.os }}-pip-${{ hashFiles('pyproject.toml', 'setup.py', 49 | 'setup.cfg') }} 50 | restore-keys: | 51 | ${{ runner.os }}-pip- 52 | 53 | - uses: twisted/python-info-action@v1 54 | - name: Install dependencies 55 | run: | 56 | python -m pip install --upgrade pip 57 | pip install .[dev] 58 | 59 | - name: Test 60 | run: | 61 | coverage run -p -m twisted.trial txacme 62 | 63 | - name: Prepare coverage 64 | if: ${{ !cancelled() }} 65 | run: | 66 | # sub-process coverage are generated in separate files so we combine them 67 | # to get an unified coverage for the local run. 68 | # The XML is generate to be used with 3rd party tools like diff-cover. 69 | python -m coverage combine 70 | python -m coverage xml -o coverage.xml -i 71 | python -m coverage report --skip-covered 72 | ls -al 73 | 74 | - uses: codecov/codecov-action@v2 75 | if: ${{ !cancelled() }} 76 | with: 77 | files: coverage.xml 78 | name: lnx-${{ matrix.python-version }} 79 | fail_ci_if_error: true 80 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info/ 2 | *.pyc 3 | .coverage 4 | .hypothesis/ 5 | .testrepository/ 6 | .tox/ 7 | _trial_temp/ 8 | build/ 9 | dist/ 10 | docs/_build 11 | docs/api/ 12 | dropin.cache 13 | -------------------------------------------------------------------------------- /.testr.conf: -------------------------------------------------------------------------------- 1 | [DEFAULT] 2 | test_command=python -m subunit.run discover -s src/ $LISTOPT $IDOPTION 3 | test_id_option=--load-list $IDFILE 4 | test_list_option=--list 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | sudo: false 3 | cache: pip 4 | if: (branch = master) OR (tag IS present) 5 | stages: 6 | - name: deploy 7 | if: tag IS present 8 | matrix: 9 | include: 10 | - python: 3.6 11 | env: TOXENV=py36-twlatest 12 | - python: 3.6 13 | env: TOXENV=py36-twlatest-alldeps 14 | - python: 3.7 15 | env: TOXENV=py37-twlatest-alldeps 16 | dist: xenial 17 | - python: pypy3.6-7.2.0 18 | dist: bionic 19 | env: TOXENV=pypy3-twlatest-alldeps 20 | - python: 3.6 21 | env: TOXENV=py36-twtrunk-acmaster-alldeps 22 | - python: 3.7 23 | env: TOXENV=py37-twtrunk-acmaster-alldeps 24 | dist: xenial 25 | - python: pypy3.6-7.2.0 26 | dist: bionic 27 | env: TOXENV=pypy3-twtrunk-acmaster-alldeps 28 | - python: 3.6 29 | env: TOXENV=py36-twlowest-alldeps 30 | - python: 3.7 31 | env: TOXENV=py37-twlowest-alldeps 32 | dist: xenial 33 | - python: pypy3.6-7.2.0 34 | dist: bionic 35 | env: TOXENV=pypy3-twlowest-alldeps 36 | - python: 3.7 37 | env: TOXENV=docs 38 | addons: 39 | apt: 40 | packages: 41 | - libenchant-dev 42 | - python: 3.6 43 | env: TOXENV=flake8 44 | allow_failures: 45 | - env: TOXENV=py36-twtrunk-acmaster-alldeps 46 | - env: TOXENV=py37-twtrunk-acmaster-alldeps 47 | - env: TOXENV=pypy3-twtrunk-acmaster-alldeps 48 | install: 49 | # Upgrade packaging tools separately, so that other installations are 50 | # performed with the upgraded tools. 51 | - pip install -U pip setuptools wheel 52 | - pip install tox codecov 53 | script: 54 | - tox 55 | after_success: 56 | # Codecov needs combined coverage, and having the raw report in the test 57 | # output can be useful. 58 | - tox -e coverage-report 59 | - codecov 60 | notifications: 61 | email: false 62 | deploy: 63 | provider: pypi 64 | user: releasebot.txacme 65 | distributions: sdist bdist_wheel 66 | on: 67 | tags: true 68 | condition: "$TOXENV = py37-twlatest-alldeps" 69 | all_branches: true 70 | password: 71 | secure: VLwvRgwwOHp6+8huOdReN8Z6OgiLtyTCGRl82mujlr+rHxSGiUfZrOXKLLVaRhKW3UpMw1Yi4F9KDWBqrbdjn5kAdbgwDrp5s1xnrxX2CeC7BP17fkW37mnY6+BqqJQzhpoiqLFgqS77es6QONosFhxrpu8PqbSlQjy5Ar/OR2NzVd9+/2uEjxw+CKGhI1WSOop7XBnFZ7b4Gjtlcw//lpzed3iOTSDLmK94m617DvhzfJEGH3a2XmkRvLbPsFD5t0KzWwC0AABDaXr7zb6DWP4lPs94x7ZDATpFHruU3m8Zsp0MKl0xIPDdcTPvAWcIpUkJ9da5VeMl049O17l1Hi+NMeAa3UsuRAch5Rp6KOOeTasZWlnk9dgse+Wu6NHCaUJx6VF/qyYkuxTEG8+9LkXeKEYVfjS+TOgKGydd6hc9jLm4U86i3fxNVcw91Ch59TljeWXRr21/ClPrJbFNcDKIBbYu4Lnpzac42w3Gle41zLmSkCvD3zbUJuNxphuOgTDd4+DnOXxmuVE1wvVUVTyVrSxzjc7L9BD5DDaw3QEy3N0az8LBp+OMOJyoRetw2sH7DTioi/7TQLFHxaV7Znv+mbJR8X/7NaNqEQSelm1rD/4PgBlcDoG1Q4Xfc42wj4RsCMxBB3ST7qhGdvD/oa8Zr/GyXwkhzGFhrXtdoio= 72 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | Contributing to txacme 2 | ###################### 3 | 4 | We use `tox` to run the test in a controller environment. 5 | 6 | Each change should consider covering the followings: 7 | 8 | * Create a release notes fragment. See section below. 9 | * Write automated tests to the point of having at least 100% code coverage. 10 | * Documenting the API. 11 | * Update the documentation with usage examples. 12 | 13 | 14 | Documenting the changes 15 | ----------------------- 16 | 17 | `towncrier `_ 18 | is used to manage the release notes. 19 | 20 | Beside the normal docstring and API documentation, 21 | each change which is visible to the users of txame should be documented in 22 | the release notes. 23 | 24 | To avoid merge conflict in the release notes files, each item of the release 25 | notes is create in a separate file located in `src/txacme/newsfragments/` 26 | 27 | The file will have the following format: ISSUE_ID.ITEM_TYPE. 28 | `ISSUE_ID` is the GitHub Issue ID targeted by this branch. 29 | 30 | `ITEM_TYPE` is one of the 31 | `default types `_ 32 | supported by Towncrier. Below is the list for your convenience (might get 33 | out of date): 34 | 35 | * .feature: Signifying a new feature. 36 | * .bugfix: Signifying a bug fix. 37 | * .doc: Signifying a documentation improvement. 38 | * .removal: Signifying a deprecation or removal of public API. 39 | * .misc: A ticket has been closed, but it is not of interest to users. 40 | 41 | 42 | Executing tests and checking coverage 43 | ------------------------------------- 44 | 45 | You can run all tests in a specific environment, or just a single test:: 46 | 47 | $ tox --develop -e py38-twlatest txacme.test.test_service 48 | $ tox --develop -e py39-twlatest \ 49 | txacme.test.test_service.AcmeIssuingServiceTests.test_timer_errors 50 | 51 | You can check the test coverage, and diff coverage by running the dedicated 52 | `coverage-report` tox env:: 53 | 54 | $ tox -e py38-twlatest,coverage-report 55 | 56 | There is a tox environment dedicated to code style checks:: 57 | 58 | $ tox -e flake8 59 | 60 | and another one for documentation and API checks:: 61 | 62 | $ tox -e docs 63 | 64 | If executing the `tox` environment is too slow for you, you can always enable 65 | a specific environment and execute the test with `trial`:: 66 | 67 | $ . .tox/py38-twlatest/bin/activate 68 | $ pip install -e . 69 | $ trial txacme.test.test_service.AcmeIssuingServiceTests.test_timer_errors 70 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | © 2016 Tristan Seligmann 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included 12 | in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 18 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 19 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 20 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst LICENSE tox.ini .coveragerc setup.cfg 2 | graft docs 3 | graft src 4 | prune src/txacme/newsfragments 5 | include versioneer.py 6 | include src/txacme/_version.py 7 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ===================================================== 2 | txacme: A Twisted implementation of the ACME protocol 3 | ===================================================== 4 | 5 | .. image:: https://readthedocs.org/projects/txacme/badge/?version=stable 6 | :target: http://txacme.readthedocs.org/en/stable/?badge=stable 7 | :alt: Documentation Status 8 | 9 | .. image:: https://github.com/twisted/txacme/actions/workflows/test.yaml/badge.svg 10 | :target: https://github.com/twisted/txacme/actions/workflows/test.yaml 11 | :alt: CI status 12 | 13 | .. image:: https://codecov.io/github/twisted/txacme/coverage.svg?branch=master 14 | :target: https://codecov.io/github/twisted/txacme?branch=master 15 | :alt: Coverage 16 | 17 | .. teaser-begin 18 | 19 | `ACME`_ is Automatic Certificate Management Environment, a protocol that allows 20 | clients and certificate authorities to automate verification and certificate 21 | issuance. The ACME protocol is used by the free `Let's Encrypt`_ Certificate 22 | Authority. 23 | 24 | ``txacme`` is an implementation of the protocol for `Twisted`_, the 25 | event-driven networking engine for Python. 26 | 27 | ``txacme`` is still under heavy development, and currently only an 28 | implementation of the client side of the protocol is planned; if you are 29 | interested in implementing or have need of the server side, please get in 30 | touch! 31 | 32 | ``txacme``\ ’s documentation lives at `Read the Docs`_, the code on `GitHub`_. 33 | It’s lightly tested on Python 3.6+, and PyPy3. 34 | 35 | .. _ACME: https://github.com/ietf-wg-acme/acme/blob/master/draft-ietf-acme-acme.md 36 | 37 | .. _Let's Encrypt: https://letsencrypt.org/ 38 | 39 | .. _Twisted: https://twistedmatrix.com/trac/ 40 | 41 | .. _Read the Docs: https://txacme.readthedocs.io/ 42 | 43 | .. _GitHub: https://github.com/twisted/txacme 44 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - src/txacme/_version.py 3 | - src/txacme/interfaces.py 4 | 5 | status: 6 | project: off 7 | patch: off 8 | -------------------------------------------------------------------------------- /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 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help 23 | help: 24 | @echo "Please use \`make ' where is one of" 25 | @echo " html to make standalone HTML files" 26 | @echo " dirhtml to make HTML files named index.html in directories" 27 | @echo " singlehtml to make a single large HTML file" 28 | @echo " pickle to make pickle files" 29 | @echo " json to make JSON files" 30 | @echo " htmlhelp to make HTML files and a HTML help project" 31 | @echo " qthelp to make HTML files and a qthelp project" 32 | @echo " applehelp to make an Apple Help Book" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | @echo " coverage to run coverage check of the documentation (if enabled)" 49 | 50 | .PHONY: clean 51 | clean: 52 | rm -rf $(BUILDDIR)/* 53 | 54 | .PHONY: html 55 | html: 56 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 57 | @echo 58 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 59 | 60 | .PHONY: dirhtml 61 | dirhtml: 62 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 63 | @echo 64 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 65 | 66 | .PHONY: singlehtml 67 | singlehtml: 68 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 69 | @echo 70 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 71 | 72 | .PHONY: pickle 73 | pickle: 74 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 75 | @echo 76 | @echo "Build finished; now you can process the pickle files." 77 | 78 | .PHONY: json 79 | json: 80 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 81 | @echo 82 | @echo "Build finished; now you can process the JSON files." 83 | 84 | .PHONY: htmlhelp 85 | htmlhelp: 86 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 87 | @echo 88 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 89 | ".hhp project file in $(BUILDDIR)/htmlhelp." 90 | 91 | .PHONY: qthelp 92 | qthelp: 93 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 94 | @echo 95 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 96 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 97 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/txacme.qhcp" 98 | @echo "To view the help file:" 99 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/txacme.qhc" 100 | 101 | .PHONY: applehelp 102 | applehelp: 103 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 104 | @echo 105 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 106 | @echo "N.B. You won't be able to view it unless you put it in" \ 107 | "~/Library/Documentation/Help or install it in your application" \ 108 | "bundle." 109 | 110 | .PHONY: devhelp 111 | devhelp: 112 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 113 | @echo 114 | @echo "Build finished." 115 | @echo "To view the help file:" 116 | @echo "# mkdir -p $$HOME/.local/share/devhelp/txacme" 117 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/txacme" 118 | @echo "# devhelp" 119 | 120 | .PHONY: epub 121 | epub: 122 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 123 | @echo 124 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 125 | 126 | .PHONY: latex 127 | latex: 128 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 129 | @echo 130 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 131 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 132 | "(use \`make latexpdf' here to do that automatically)." 133 | 134 | .PHONY: latexpdf 135 | latexpdf: 136 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 137 | @echo "Running LaTeX files through pdflatex..." 138 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 139 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 140 | 141 | .PHONY: latexpdfja 142 | latexpdfja: 143 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 144 | @echo "Running LaTeX files through platex and dvipdfmx..." 145 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 146 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 147 | 148 | .PHONY: text 149 | text: 150 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 151 | @echo 152 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 153 | 154 | .PHONY: man 155 | man: 156 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 157 | @echo 158 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 159 | 160 | .PHONY: texinfo 161 | texinfo: 162 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 163 | @echo 164 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 165 | @echo "Run \`make' in that directory to run these through makeinfo" \ 166 | "(use \`make info' here to do that automatically)." 167 | 168 | .PHONY: info 169 | info: 170 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 171 | @echo "Running Texinfo files through makeinfo..." 172 | make -C $(BUILDDIR)/texinfo info 173 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 174 | 175 | .PHONY: gettext 176 | gettext: 177 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 178 | @echo 179 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 180 | 181 | .PHONY: changes 182 | changes: 183 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 184 | @echo 185 | @echo "The overview file is in $(BUILDDIR)/changes." 186 | 187 | .PHONY: linkcheck 188 | linkcheck: 189 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 190 | @echo 191 | @echo "Link check complete; look for any errors in the above output " \ 192 | "or in $(BUILDDIR)/linkcheck/output.txt." 193 | 194 | .PHONY: doctest 195 | doctest: 196 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 197 | @echo "Testing of doctests in the sources finished, look at the " \ 198 | "results in $(BUILDDIR)/doctest/output.txt." 199 | 200 | .PHONY: coverage 201 | coverage: 202 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 203 | @echo "Testing of coverage in the sources finished, look at the " \ 204 | "results in $(BUILDDIR)/coverage/python.txt." 205 | 206 | .PHONY: xml 207 | xml: 208 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 209 | @echo 210 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 211 | 212 | .PHONY: pseudoxml 213 | pseudoxml: 214 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 215 | @echo 216 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 217 | -------------------------------------------------------------------------------- /docs/api-stability.rst: -------------------------------------------------------------------------------- 1 | API stability 2 | ============= 3 | 4 | txacme is versioned according to `SemVer 2.0.0`_. In addition, since SemVer 5 | does not make this explicit, versions following txacme 1.0.0 will have a 6 | "rolling compatibility" guarantee: new major versions will not break behaviour 7 | that did not already emit a deprecation warning in the latest minor version of 8 | the previous major version series. 9 | 10 | The current version number of 0.9.x is intended to reflect the 11 | not-quite-finalized nature of the API. While it is not expected that the API 12 | will change drastically, the 0.9 version series is intended to allow space for 13 | users to experiment and identify any issues obstructing their use cases so that 14 | these can be corrected before the API is finalized in the 1.0.0 release. 15 | 16 | .. _SemVer 2.0.0: http://semver.org/spec/v2.0.0.html 17 | -------------------------------------------------------------------------------- /docs/certs-dir.rst: -------------------------------------------------------------------------------- 1 | Certificates directory 2 | ====================== 3 | 4 | The layout of the certificates directory used by ``DirectoryStore`` (and thus 5 | the ``le:`` and ``lets:`` endpoints) is coordinated with `txsni`_ to allow 6 | sharing a certificates directory with other applications. The txsni and txacme 7 | maintainers have committed to coordination of any future changes to the 8 | contents of this directory to ensure continued compatibility. 9 | 10 | .. _txsni: https://github.com/glyph/txsni 11 | 12 | At present, the following entries may exist in this directory: 13 | 14 | * ``.pem`` 15 | 16 | A file containing a certificate and matching private key valid for ````, serialized in PEM format. 18 | 19 | * ``client.key`` 20 | 21 | A file containing an ACME client key, serialized in PEM format. 22 | 23 | All other filenames are currently reserved for future use; introducing 24 | non-specified files or directories into a certificates directory may result in 25 | conflicts with items specified by future versions of txacme and/or txsni. 26 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | txacme changelog 2 | ~~~~~~~~~~~~~~~~ 3 | 4 | .. towncrier release notes start 5 | 6 | Txacme 0.9.3 (2020-04-16) 7 | ========================= 8 | 9 | Bugfixes 10 | -------- 11 | 12 | - Become installable on current versions of attrs again. (#137) 13 | 14 | 15 | Deprecations and Removals 16 | ------------------------- 17 | 18 | - INCOMPATIBLE CHANGE: Removed ``txacme.util.key_cryptography_to_pyopenssl`` and ``txacme.util.cert_cryptography_to_pyopenssl`` in favour of using the native PyOpenSSL conversion methods. (#122) 19 | 20 | 21 | Txacme 0.9.2 (2018-01-24) 22 | ========================= 23 | 24 | Features 25 | -------- 26 | 27 | - The default client timeout is now 40 seconds to allow Let's Encrypt's server 28 | side timeout of 30 seconds to kick in first. (#111) 29 | 30 | 31 | Misc 32 | ---- 33 | 34 | - #115 35 | 36 | 37 | Txacme 0.9.1 (2016-12-08) 38 | ========================= 39 | 40 | Features 41 | -------- 42 | 43 | - INCOMPATIBLE CHANGE: AcmeIssuingService now takes a client creator, 44 | rather than a client, and invokes it for every issuing attempt. 45 | (#21) 46 | - INCOMPATIBLE CHANGE: The ``*_DIRECTORY`` constants are now in 47 | txacme.urls. (#28) 48 | - INCOMPATIBLE CHANGE: ``IResponder.start_responding`` and 49 | ``IResponder.stop_responding`` now take the server_name and 50 | challenge object in addition to the challenge response object. (#60) 51 | - AcmeIssuingService now logs info messages about what it is doing. 52 | (#38) 53 | - txacme.challenges.LibcloudDNSResponder implements a dns-01 challenge 54 | responder using libcloud. Installing txacme[libcloud] is necessary 55 | to pull in the dependencies for this. (#59) 56 | - ``txacme.challenges.HTTP01Responder``, an http-01 challenge 57 | responder that can be embedded into an existing twisted.web 58 | application. (#65) 59 | - ``txacme.endpoint.load_or_create_client_key`` gets a client key from 60 | the certs directory, using the same logic as the endpoints. (#71) 61 | - ``AcmeIssuingService`` now accepts an ``email`` parameter which it 62 | adds to the ACME registration. In addition, existing registrations 63 | are updated with this email address. (#72) 64 | - ``AcmeIssuingService`` now has a public ``issue_cert`` method for 65 | safely issuing a new cert on demand. (#76) 66 | 67 | Bugfixes 68 | -------- 69 | 70 | - ``txacme.client.JWSClient`` now automatically retries a POST request 71 | that fails with a ``badNonce`` error. (#66) 72 | - ``txacme.store.DirectoryStore`` now handles bytes mode paths 73 | correctly. (#68) 74 | - The txacme endpoint plugin now lazily imports the rest of the code, 75 | avoiding ReactorAlreadyInstalled errors in various cases. (#79) 76 | 77 | Improved Documentation 78 | ---------------------- 79 | 80 | - The contents of the certificates directory, and compatibility with 81 | txsni, is now documented. (#35) 82 | 83 | Misc 84 | ---- 85 | 86 | - #67 87 | 88 | 89 | Txacme 0.9.0 (2016-04-10) 90 | ========================= 91 | 92 | Features 93 | -------- 94 | 95 | - Initial release! (#23) 96 | -------------------------------------------------------------------------------- /docs/client_example.py: -------------------------------------------------------------------------------- 1 | """ 2 | This wants to be a complex example demonstrating all the txacme client 3 | capabilities. 4 | 5 | Each time it starts, if one is not defined, it will generate a new 6 | private key and register a new account. 7 | 8 | It can also start a pebble ACME server and run tests against it... or you can 9 | use your one ACME server. 10 | 11 | It uses `.tox` as the build directory. 12 | 13 | https://www.rfc-editor.org/rfc/rfc8555.html 14 | 15 | Example usage: 16 | 17 | # Copy pebble binary to the following path: /tmp/pebble_linux-amd64 18 | # Update /etc/hosts and add test.local and www.test.local as names for 19 | 127.0.0.1 20 | 21 | # Make sure all python txacme dependencies are installed. 22 | $ python docs/client_example.py test.local 23 | 24 | # After it starts and all is fine you could connect to https://test.local:5003 25 | # and see the new certificate. 26 | """ 27 | from __future__ import unicode_literals, print_function 28 | from threading import Thread 29 | import os 30 | import socket 31 | import sys 32 | import time 33 | 34 | from acme.errors import ClientError 35 | from cryptography.hazmat.backends import default_backend 36 | from cryptography.hazmat.primitives import serialization 37 | 38 | from eliot import add_destinations 39 | from eliot.parse import Parser 40 | from eliottree import render_tasks 41 | 42 | from josepy.jwa import RS256 43 | from josepy.jwk import JWKRSA 44 | from twisted.internet import reactor, defer, ssl, task 45 | from twisted.internet.endpoints import TCP4ServerEndpoint 46 | from twisted.web import http 47 | from twisted.web.resource import Resource 48 | from twisted.web.server import Site 49 | from twisted.python.url import URL 50 | from zope.interface import implementer 51 | 52 | from txacme.client import ( 53 | answer_challenge, 54 | Client, 55 | get_certificate, 56 | ) 57 | from txacme.interfaces import IResponder 58 | from txacme.util import generate_private_key 59 | 60 | # WARNING. THIS DISABLES SSL validation in twisted client. 61 | # Is here to make the example easier to read. 62 | import twisted.internet._sslverify as v 63 | v.platformTrust = lambda: None 64 | 65 | # Update it to the path of your pebble executable. 66 | # Download it from https://github.com/letsencrypt/pebble/releases 67 | # This script can automatically start a pebble instance, but in that case 68 | # already registered accounts are not persisted. 69 | PEBBLE = "/tmp/pebble_linux-amd64 --config docs/pebble-config.json" 70 | 71 | # Copy inside an exiting private key and check that account is reused. 72 | # Or leave it empty to have the key automatically generated. 73 | ACCOUNT_KEY_PEM = """ 74 | -----BEGIN RSA PRIVATE KEY----- 75 | MIIEpQIBAAKCAQEAqJc17HS3PftQZzharEnOpdW1eCxJvqHuiciolx6qtu1X3YIa 76 | PlG/e36oL4ENqMekJ/caEISMr0y1OUi6NVvjWisZpJXCg1RHwrSAw8/pYaE8IIrs 77 | ffPd6Y8R/sTSDGVCKFkx5R4e4VRmimfrZlnNPFeAFXvfgKM3ZmavN1KUoaghQktr 78 | /NpmKCzSaBViMv1LpqsXh6xCyRRbT3hbRcxDNK+m5rgq7Xg6XSkV4eKYtZYrGHyU 79 | lydKEmxmrDawk71YRgSsAWGDLro/tsCUMIKPQoz+cQwwaWdbABAHStUPwARCiuHG 80 | wIa2slAckbPOgwzCLL/mt+sBXQnATtIrukci/QIDAQABAoIBAQCI5pExO+349PTr 81 | fMWUljKqU4oS1dPka1ZqqHjOjmaOONla1GU/Kd7WB5nHSYKwBb31fiC6PQiI6T9Y 82 | Dwi2f7F07P7buYjEYFINd8oAN/sJ/oX23xj/hmIzYKx6N5Vh33ADl7p+lSD6VTEX 83 | Px/WcyHH2D34NCjgKqm4C7ZItFRhl/ZAbSs1GNuN3TvlBhUfR/nr3pYxhZ/7cY1k 84 | LozhARbT74Xsa8layl5cs2r2jXQRfBRXF8D1dSxp0zhiI3V1ywmHsTWYtEjofTgN 85 | iJI5e+e5csl/ZlbQVJCx6oIxDumymZ+cwEQN4NB95g2mX2qfmmBlB2uuFaEldCx7 86 | hnKvBc35AoGBANQqaWX1GwgOti279ezfyIELTgT2CBsWqxOAtC+p9Jb5e9Pkvuo0 87 | wreVgf+lcgCQNr972MmNWYtQoFgP8VDfUT6+RdVClmoglAjbUbSlPSXiJ8AGOOqD 88 | XxwB0RhZTdhXUsu9L+QDpDF4+kpPQNEZTUHpL7TLMhvM80c2cgTnmSQPAoGBAMts 89 | FkQrdatIoWY2fUoQNhWe/PhSpSOGpgDE2PcmDhuseeI1dzCzOfvaQqgqI5iVgxrS 90 | AYdDdeXwELhB1hAk8d7jUA8HBG4b7PfHkCmQ9NdYb8YoBOpVilL0OYuSf4kQJYdI 91 | Ody+Tfa6DxnYjrZW24uyQGMUa8ex1MrG/R00Z8wzAoGBAKh4pQjZAIX9aJwYTMez 92 | SzttBp7Z3sXj0iTCZlIS2q2nnbQ8R30iOBwfFAM0FLptyYtzhElHfHsroqdKwYw+ 93 | R/1SiZE2Nso+5E3EGbUgINYcJwRL7JYLi1Jp/ucewrmvXYd6yrR8T70ZG2Y2WHmx 94 | Za+YwtEFKNz6eZNqoE9UuD3xAoGBAJwE3ZsRXiGuBiRYHIYmouS4WTu4X3I8/qtO 95 | Tz5X0LBG/ACUk0Ml444YG9HQ6BZKbhCvC38MLavbEWfRDva4703NOIUeE7bD8l8k 96 | j5xh0ngsGyZ3YTW9v+bZ7BzxkqG0YaQ9sCtvRmq6z4Q6RVLykVa2s42KhxPVf+i6 97 | 8D1rCUVjAoGAc8kja8SUE5uxQZHfBlD7MXS7wNqRsginnI0+gZrmOQLAM2fUTcP9 98 | aLhPkFOG4jTZGg3TwV5XNk+f/nh4Ps+GKYng6MneKzN9+mcZ30T54e+1gd+chz9X 99 | yLwHm1esy/txlB27jQ26/8LabbLRQkFCiXxdJ9W1+TEWoq0YT25Awek= 100 | -----END RSA PRIVATE KEY----- 101 | """.strip() 102 | # Uncomment this to have a new account key generated at each run. 103 | # ACCOUNT_KEY_PEM = '' 104 | 105 | 106 | class EliotTreeDestination: 107 | def __init__(self, out=sys.stdout.write, **opts): 108 | self.out = out 109 | self.opts = opts 110 | self._parser = Parser() 111 | 112 | def __call__(self, message): 113 | tasks, self._parser = self._parser.add(message) 114 | 115 | if tasks: 116 | render_tasks(self.out, tasks, **self.opts) 117 | 118 | 119 | def _get_account_key(): 120 | """ 121 | Return the private key to be used for ACME interaction. 122 | """ 123 | if ACCOUNT_KEY_PEM: 124 | return serialization.load_pem_private_key( 125 | ACCOUNT_KEY_PEM.encode('ascii'), 126 | password=None, 127 | backend=default_backend(), 128 | ) 129 | # We don't have a key...so generate one. 130 | key = generate_private_key('rsa') 131 | account_key = key.private_bytes( 132 | encoding=serialization.Encoding.PEM, 133 | format=serialization.PrivateFormat.TraditionalOpenSSL, 134 | encryption_algorithm=serialization.NoEncryption(), 135 | ) 136 | print('New account key generated:\n%s' % (account_key)) 137 | return key 138 | 139 | 140 | class StaticTextResource(Resource, object): 141 | """ 142 | A resource returning a static page... is a placeholder page. 143 | """ 144 | def __init__(self, content='', content_type='text/plain', code=http.OK): 145 | self._content = content.encode('utf-8') 146 | self._content_type = content_type.encode('ascii') 147 | self._code = code 148 | super(StaticTextResource, self).__init__() 149 | 150 | def getChild(self, name, request): 151 | """ 152 | Called when no other resources are attached. 153 | """ 154 | return self 155 | 156 | def render(self, request): 157 | """ 158 | Return the same content. 159 | """ 160 | request.setHeader(b'Content-Type', self._content_type) 161 | request.setResponseCode(self._code) 162 | return self._content 163 | 164 | 165 | @implementer(IResponder) 166 | class HTTP01Responder(StaticTextResource): 167 | """ 168 | Web resource for ``http-01`` challenge responder. 169 | 170 | Beside the challenge pages, it displays empty pages. 171 | """ 172 | challenge_type = u'http-01' 173 | 174 | def __init__(self): 175 | super(HTTP01Responder, self).__init__('') 176 | # Add a static response to help with connection troubleshooting. 177 | self.putChild(b'test.txt', StaticTextResource('Let\'s Encrypt Ready')) 178 | 179 | def start_responding(self, ignored, challenge, response): 180 | """ 181 | Prepare for the ACME server to validate the challenge. 182 | """ 183 | self.putChild( 184 | challenge.encode('token').encode('utf-8'), 185 | StaticTextResource(response.key_authorization), 186 | ) 187 | 188 | def stop_responding(self, ignored, challenge, ignored1): 189 | """ 190 | Remove the child resource once the process is done. 191 | """ 192 | encoded_token = challenge.encode('token').encode('utf-8') 193 | if self.getStaticEntity(encoded_token) is not None: 194 | self.delEntity(encoded_token) 195 | 196 | 197 | def start_http01_server(port=5002): 198 | """ 199 | Start an HTTP server which handles HTTP-01 changeless. 200 | """ 201 | responder = HTTP01Responder() 202 | root = StaticTextResource( 203 | 'Just a test server. Main thing in: ' 204 | '.well-known/acme-challenge/test.txt' 205 | ) 206 | well_known = StaticTextResource('') 207 | root.putChild('.well-known', well_known) 208 | well_known.putChild('acme-challenge', responder) 209 | 210 | endpoint = TCP4ServerEndpoint( 211 | reactor=reactor, 212 | interface='0.0.0.0', 213 | port=port, 214 | ) 215 | deferred = endpoint.listen(Site(root)) 216 | deferred.addCallback(lambda result: (result, responder)) 217 | return deferred 218 | 219 | 220 | def start_https_demo_server(key, certificate_chain, port=5003): 221 | """ 222 | Start a demo HTTPS server which uses the generate certificate. 223 | """ 224 | wait = 60 225 | root = StaticTextResource('Hello ACME!') 226 | certificate = ssl.PrivateCertificate.loadPEM(key + certificate_chain) 227 | reactor.listenSSL(port, Site(root), certificate.options()) 228 | print('New HTTPS server listening on port %s for the next %s seconds' % ( 229 | port, wait)) 230 | return task.deferLater(reactor, wait, lambda: None) 231 | 232 | 233 | def start_acme_server(): 234 | """ 235 | Start a local pebble ACME v2 server. 236 | """ 237 | # Pebble testing files from 238 | # https://github.com/letsencrypt/pebble/tree/master/test/certs/localhost 239 | key = """ 240 | -----BEGIN RSA PRIVATE KEY----- 241 | MIIEowIBAAKCAQEAmxTFtw113RK70H9pQmdKs9AxhFmnQ6BdDtp3jOZlWlUO0Blt 242 | MXOUML5905etgtCbcC6RdKRtgSAiDfgx3VWiFMJH++4gUtnaB9SN8GhNSPBpFfSa 243 | 2JhWPo9HQNUsAZqlGTV4SzcGRqtWvdZxUiOfQ2TcvyXIqsaD19ivvqI1NhT6bl3t 244 | redTZlzLLM6Wvkw6hfyHrJAPQP8LOlCIeDM4YIce6Gstv6qo9iCD4wJiY4u95HVL 245 | 7RK8t8JpZAb7VR+dPhbHEvVpjwuYd5Q05OZ280gFyrhbrKLbqst104GOQT4kQMJG 246 | WxGONyTX6np0Dx6O5jU7dvYvjVVawbJwGuaL6wIDAQABAoIBAGW9W/S6lO+DIcoo 247 | PHL+9sg+tq2gb5ZzN3nOI45BfI6lrMEjXTqLG9ZasovFP2TJ3J/dPTnrwZdr8Et/ 248 | 357YViwORVFnKLeSCnMGpFPq6YEHj7mCrq+YSURjlRhYgbVPsi52oMOfhrOIJrEG 249 | ZXPAwPRi0Ftqu1omQEqz8qA7JHOkjB2p0i2Xc/uOSJccCmUDMlksRYz8zFe8wHuD 250 | XvUL2k23n2pBZ6wiez6Xjr0wUQ4ESI02x7PmYgA3aqF2Q6ECDwHhjVeQmAuypMF6 251 | IaTjIJkWdZCW96pPaK1t+5nTNZ+Mg7tpJ/PRE4BkJvqcfHEOOl6wAE8gSk5uVApY 252 | ZRKGmGkCgYEAzF9iRXYo7A/UphL11bR0gqxB6qnQl54iLhqS/E6CVNcmwJ2d9pF8 253 | 5HTfSo1/lOXT3hGV8gizN2S5RmWBrc9HBZ+dNrVo7FYeeBiHu+opbX1X/C1HC0m1 254 | wJNsyoXeqD1OFc1WbDpHz5iv4IOXzYdOdKiYEcTv5JkqE7jomqBLQk8CgYEAwkG/ 255 | rnwr4ThUo/DG5oH+l0LVnHkrJY+BUSI33g3eQ3eM0MSbfJXGT7snh5puJW0oXP7Z 256 | Gw88nK3Vnz2nTPesiwtO2OkUVgrIgWryIvKHaqrYnapZHuM+io30jbZOVaVTMR9c 257 | X/7/d5/evwXuP7p2DIdZKQKKFgROm1XnhNqVgaUCgYBD/ogHbCR5RVsOVciMbRlG 258 | UGEt3YmUp/vfMuAsKUKbT2mJM+dWHVlb+LZBa4pC06QFgfxNJi/aAhzSGvtmBEww 259 | xsXbaceauZwxgJfIIUPfNZCMSdQVIVTi2Smcx6UofBz6i/Jw14MEwlvhamaa7qVf 260 | kqflYYwelga1wRNCPopLaQKBgQCWsZqZKQqBNMm0Q9yIhN+TR+2d7QFjqeePoRPl 261 | 1qxNejhq25ojE607vNv1ff9kWUGuoqSZMUC76r6FQba/JoNbefI4otd7x/GzM9uS 262 | 8MHMJazU4okwROkHYwgLxxkNp6rZuJJYheB4VDTfyyH/ng5lubmY7rdgTQcNyZ5I 263 | majRYQKBgAMKJ3RlII0qvAfNFZr4Y2bNIq+60Z+Qu2W5xokIHCFNly3W1XDDKGFe 264 | CCPHSvQljinke3P9gPt2HVdXxcnku9VkTti+JygxuLkVg7E0/SWwrWfGsaMJs+84 265 | fK+mTZay2d3v24r9WKEKwLykngYPyZw5+BdWU0E+xx5lGUd3U4gG 266 | -----END RSA PRIVATE KEY----- 267 | """ 268 | cert = """ 269 | -----BEGIN CERTIFICATE----- 270 | MIIDGzCCAgOgAwIBAgIIbEfayDFsBtwwDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE 271 | AxMVbWluaWNhIHJvb3QgY2EgMjRlMmRiMCAXDTE3MTIwNjE5NDIxMFoYDzIxMDcx 272 | MjA2MTk0MjEwWjAUMRIwEAYDVQQDEwlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB 273 | AQUAA4IBDwAwggEKAoIBAQCbFMW3DXXdErvQf2lCZ0qz0DGEWadDoF0O2neM5mVa 274 | VQ7QGW0xc5Qwvn3Tl62C0JtwLpF0pG2BICIN+DHdVaIUwkf77iBS2doH1I3waE1I 275 | 8GkV9JrYmFY+j0dA1SwBmqUZNXhLNwZGq1a91nFSI59DZNy/JciqxoPX2K++ojU2 276 | FPpuXe2t51NmXMsszpa+TDqF/IeskA9A/ws6UIh4Mzhghx7oay2/qqj2IIPjAmJj 277 | i73kdUvtEry3wmlkBvtVH50+FscS9WmPC5h3lDTk5nbzSAXKuFusotuqy3XTgY5B 278 | PiRAwkZbEY43JNfqenQPHo7mNTt29i+NVVrBsnAa5ovrAgMBAAGjYzBhMA4GA1Ud 279 | DwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwDAYDVR0T 280 | AQH/BAIwADAiBgNVHREEGzAZgglsb2NhbGhvc3SCBnBlYmJsZYcEfwAAATANBgkq 281 | hkiG9w0BAQsFAAOCAQEAYIkXff8H28KS0KyLHtbbSOGU4sujHHVwiVXSATACsNAE 282 | D0Qa8hdtTQ6AUqA6/n8/u1tk0O4rPE/cTpsM3IJFX9S3rZMRsguBP7BSr1Lq/XAB 283 | 7JP/CNHt+Z9aKCKcg11wIX9/B9F7pyKM3TdKgOpqXGV6TMuLjg5PlYWI/07lVGFW 284 | /mSJDRs8bSCFmbRtEqc4lpwlrpz+kTTnX6G7JDLfLWYw/xXVqwFfdengcDTHCc8K 285 | wtgGq/Gu6vcoBxIO3jaca+OIkMfxxXmGrcNdseuUCa3RMZ8Qy03DqGu6Y6XQyK4B 286 | W8zIG6H9SVKkAznM2yfYhW8v2ktcaZ95/OBHY97ZIw== 287 | -----END CERTIFICATE----- 288 | """ 289 | try: 290 | os.makedirs('.tox/pebble/certs/localhost') 291 | except OSError: 292 | pass 293 | 294 | with open('.tox/pebble/certs/localhost/key.pem', 'w') as stream: 295 | stream.write(key) 296 | with open('.tox/pebble/certs/localhost/cert.pem', 'w') as stream: 297 | stream.write(cert) 298 | 299 | thread = Thread(target=lambda: os.system(PEBBLE)) 300 | thread.start() 301 | 302 | # Wait for pebble to start. 303 | wait = 0.5 304 | s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 305 | for _ in range(5): 306 | try: 307 | s.connect(('127.0.0.1', 14000)) 308 | except Exception as error: 309 | time.sleep(wait) 310 | wait += wait 311 | else: 312 | s.close() 313 | return thread 314 | 315 | raise error 316 | 317 | 318 | @defer.inlineCallbacks 319 | def start_responders(): 320 | port, http01_responder = yield start_http01_server() 321 | defer.returnValue([http01_responder]) 322 | 323 | 324 | @defer.inlineCallbacks 325 | def get_things_done(): 326 | """ 327 | Here is where the client part is setup and action is done. 328 | """ 329 | responders = yield start_responders() 330 | 331 | # We first validate the directory. 332 | account_key = _get_account_key() 333 | try: 334 | client = yield Client.from_url( 335 | reactor, 336 | URL.fromText(acme_url.decode('utf-8')), 337 | key=JWKRSA(key=account_key), 338 | alg=RS256, 339 | ) 340 | except Exception as error: 341 | print('\n\nFailed to connect to ACME directory. %s' % (error,)) 342 | yield reactor.stop() 343 | defer.returnValue(None) 344 | 345 | # Then we register a new account or update an existing account. 346 | # First register a new account with a contact set, then using the same 347 | # key call register with a different contact and see that it was updated. 348 | response = yield client.start( 349 | email='txacme-test1@twstedmatrix.org,txacme-test2@twstedmatrix.org') 350 | 351 | print('Account URI: %s' % (response.uri,)) 352 | print('Account contact: %s' % (response.body.contact,)) 353 | 354 | # We request a single certificate for a list of domains and get an "order" 355 | cert_key = generate_private_key('rsa') 356 | orderr = yield client.submit_order(cert_key, requested_domains) 357 | 358 | # Each order had a list of "authorizations" for which the challenge needs 359 | # to be validated. 360 | for authorization in orderr.authorizations: 361 | try: 362 | # Make sure all ACME server requests are sequential. 363 | # For now, answering to the challenges in parallel will not work. 364 | yield answer_challenge( 365 | authorization, client, responders, clock=reactor) 366 | except Exception as error: 367 | print('\n\nFailed to validate a challenge. %s' % (error,)) 368 | yield reactor.stop() 369 | defer.returnValue(None) 370 | 371 | certificate = yield get_certificate(orderr, client, clock=reactor) 372 | 373 | print('Got a new cert:\n') 374 | print(certificate.body) 375 | 376 | cert_key_pem = cert_key.private_bytes( 377 | encoding=serialization.Encoding.PEM, 378 | format=serialization.PrivateFormat.PKCS8, 379 | encryption_algorithm=serialization.NoEncryption(), 380 | ) 381 | 382 | # Cleanup the client and disconnect any persistent connection to the 383 | # ACME server. 384 | yield client.stop() 385 | 386 | # The new certificate is available and we can start a demo HTTPS server 387 | # using it. 388 | yield start_https_demo_server(cert_key_pem, certificate.body) 389 | print('txacme demo done.') 390 | 391 | 392 | def stop(): 393 | """ 394 | Stop and cleanup the whole shebang. 395 | """ 396 | if pebble_thread: 397 | pebble_thread.join(5) 398 | print('Press Ctrl+C to end the process.') 399 | 400 | 401 | def eb_client_failure(failure): 402 | """ 403 | Called when any of the client operation fails. 404 | """ 405 | failure.trap(ClientError) 406 | print (failure.value) 407 | 408 | 409 | def eb_general_failure(failure): 410 | """ 411 | Called when any operation fails. 412 | """ 413 | print(failure) 414 | 415 | 416 | def show_usage(): 417 | """ 418 | Show the help on how to use the command. 419 | """ 420 | print('Usage: %s REQUSTED_DOMAINS [API_ENDPOINT]\n' % (sys.argv[0],)) 421 | print('REQUSTED_DOMAINS -> comma separated list of domains') 422 | print('It will start a local PEBBLE if API_ENDPOINT is not provided.') 423 | 424 | print('\nACME v2 endpoints:') 425 | print('[Production] https://acme-v02.api.letsencrypt.org/directory') 426 | print('[Staging] https://acme-staging-v02.api.letsencrypt.org/directory') 427 | sys.exit(1) 428 | 429 | 430 | for arg in sys.argv: 431 | if arg.lower().strip() in ['-h', '--help']: 432 | show_usage() 433 | 434 | 435 | if len(sys.argv) < 2: 436 | show_usage() 437 | 438 | 439 | pebble_thread = None 440 | try: 441 | acme_url = sys.argv[2] 442 | except IndexError: 443 | pebble_thread = start_acme_server() 444 | acme_url = 'https://localhost:14000/dir' 445 | 446 | 447 | requested_domains = [d.strip().decode('utf-8') for d in sys.argv[1].split(',')] 448 | 449 | print('\n\n') 450 | print('-' * 70) 451 | print('Using ACME at %s' % (acme_url,)) 452 | print('Requesting a single certificate for %s' % (requested_domains,)) 453 | print( 454 | 'HTTP-01 responser at ' 455 | 'http://localhost:5002/.well-known/acme-challenge/test.txt' 456 | ) 457 | print('-' * 70) 458 | print('\n\n') 459 | 460 | add_destinations(EliotTreeDestination( 461 | colorize=True, colorize_tree=True, human_readable=True)) 462 | 463 | 464 | def main(reactor): 465 | d = get_things_done() 466 | d.addErrback(eb_client_failure) 467 | d.addErrback(eb_general_failure) 468 | d.addBoth(lambda _: stop()) 469 | return d 470 | 471 | 472 | if __name__ == '__main__': 473 | task.react(main) 474 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # txacme documentation build configuration file, created by 4 | # sphinx-quickstart on Wed Feb 24 09:42:24 2016. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import os 16 | import subprocess 17 | import sys 18 | 19 | try: 20 | import sphinx_rtd_theme 21 | except ImportError: 22 | sphinx_rtd_theme = None 23 | 24 | # If extensions (or modules to document with autodoc) are in another directory, 25 | # add these directories to sys.path here. If the directory is relative to the 26 | # documentation root, use os.path.abspath to make it absolute, like shown here. 27 | #sys.path.insert(0, os.path.abspath('.')) 28 | 29 | # -- General configuration ------------------------------------------------ 30 | 31 | # If your documentation needs a minimal Sphinx version, state it here. 32 | #needs_sphinx = '1.0' 33 | 34 | # Add any Sphinx extension module names here, as strings. They can be 35 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 36 | # ones. 37 | extensions = [ 38 | 'sphinx.ext.autodoc', 39 | 'sphinx.ext.doctest', 40 | 'sphinx.ext.intersphinx', 41 | 'sphinx.ext.linkcode', 42 | 'sphinx.ext.todo', 43 | 'repoze.sphinx.autointerface', 44 | ] 45 | 46 | # Add any paths that contain templates here, relative to this directory. 47 | templates_path = ['_templates'] 48 | 49 | # The suffix(es) of source filenames. 50 | # You can specify multiple suffix as a list of string: 51 | # source_suffix = ['.rst', '.md'] 52 | source_suffix = '.rst' 53 | 54 | # The encoding of source files. 55 | #source_encoding = 'utf-8-sig' 56 | 57 | # The master toctree document. 58 | master_doc = 'index' 59 | 60 | # General information about the project. 61 | project = u'txacme' 62 | copyright = u'2016, Tristan Seligmann' 63 | author = u'Tristan Seligmann' 64 | 65 | # The version info for the project you're documenting, acts as replacement for 66 | # |version| and |release|, also used in various other places throughout the 67 | # built documents. 68 | 69 | sys.path.insert(0, '.') 70 | sys.path.insert(0, '../src') 71 | from txacme import __version__, _version 72 | version = release = __version__ 73 | txacme_version_info = _version.get_versions() 74 | 75 | # The language for content autogenerated by Sphinx. Refer to documentation 76 | # for a list of supported languages. 77 | # 78 | # This is also used if you do content translation via gettext catalogs. 79 | # Usually you set "language" from the command line for these cases. 80 | language = None 81 | 82 | # There are two options for replacing |today|: either, you set today to some 83 | # non-false value, then it is used: 84 | #today = '' 85 | # Else, today_fmt is used as the format for a strftime call. 86 | #today_fmt = '%B %d, %Y' 87 | 88 | # List of patterns, relative to source directory, that match files and 89 | # directories to ignore when looking for source files. 90 | exclude_patterns = ['_build'] 91 | 92 | # The reST default role (used for this markup: `text`) to use for all 93 | # documents. 94 | default_role = 'py:obj' 95 | 96 | # If true, '()' will be appended to :func: etc. cross-reference text. 97 | #add_function_parentheses = True 98 | 99 | # If true, the current module name will be prepended to all description 100 | # unit titles (such as .. function::). 101 | #add_module_names = True 102 | 103 | # If true, sectionauthor and moduleauthor directives will be shown in the 104 | # output. They are ignored by default. 105 | #show_authors = False 106 | 107 | # The name of the Pygments (syntax highlighting) style to use. 108 | pygments_style = 'sphinx' 109 | 110 | # A list of ignored prefixes for module index sorting. 111 | #modindex_common_prefix = [] 112 | 113 | # If true, keep warnings as "system message" paragraphs in the built documents. 114 | #keep_warnings = False 115 | 116 | # If true, `todo` and `todoList` produce output, else they produce nothing. 117 | todo_include_todos = True 118 | 119 | 120 | # -- Options for HTML output ---------------------------------------------- 121 | 122 | # The theme to use for HTML and HTML Help pages. See the documentation for 123 | # a list of builtin themes. 124 | if sphinx_rtd_theme: 125 | html_theme = "sphinx_rtd_theme" 126 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 127 | else: 128 | html_theme = "default" 129 | 130 | # Theme options are theme-specific and customize the look and feel of a theme 131 | # further. For a list of options available for each theme, see the 132 | # documentation. 133 | #html_theme_options = {} 134 | 135 | # Add any paths that contain custom themes here, relative to this directory. 136 | #html_theme_path = [] 137 | 138 | # The name for this set of Sphinx documents. If None, it defaults to 139 | # " v documentation". 140 | #html_title = None 141 | 142 | # A shorter title for the navigation bar. Default is the same as html_title. 143 | #html_short_title = None 144 | 145 | # The name of an image file (relative to this directory) to place at the top 146 | # of the sidebar. 147 | #html_logo = None 148 | 149 | # The name of an image file (within the static path) to use as favicon of the 150 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 151 | # pixels large. 152 | #html_favicon = None 153 | 154 | # Add any paths that contain custom static files (such as style sheets) here, 155 | # relative to this directory. They are copied after the builtin static files, 156 | # so a file named "default.css" will overwrite the builtin "default.css". 157 | #html_static_path = ['_static'] 158 | 159 | # Add any extra paths that contain custom files (such as robots.txt or 160 | # .htaccess) here, relative to this directory. These files are copied 161 | # directly to the root of the documentation. 162 | #html_extra_path = [] 163 | 164 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 165 | # using the given strftime format. 166 | #html_last_updated_fmt = '%b %d, %Y' 167 | 168 | # If true, SmartyPants will be used to convert quotes and dashes to 169 | # typographically correct entities. 170 | #html_use_smartypants = True 171 | 172 | # Custom sidebar templates, maps document names to template names. 173 | #html_sidebars = {} 174 | 175 | # Additional templates that should be rendered to pages, maps page names to 176 | # template names. 177 | #html_additional_pages = {} 178 | 179 | # If false, no module index is generated. 180 | html_domain_indices = True 181 | 182 | # If false, no index is generated. 183 | #html_use_index = True 184 | 185 | # If true, the index is split into individual pages for each letter. 186 | #html_split_index = False 187 | 188 | # If true, links to the reST sources are added to the pages. 189 | #html_show_sourcelink = True 190 | 191 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 192 | #html_show_sphinx = True 193 | 194 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 195 | #html_show_copyright = True 196 | 197 | # If true, an OpenSearch description file will be output, and all pages will 198 | # contain a tag referring to it. The value of this option must be the 199 | # base URL from which the finished HTML is served. 200 | #html_use_opensearch = '' 201 | 202 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 203 | #html_file_suffix = None 204 | 205 | # Language to be used for generating the HTML full-text search index. 206 | # Sphinx supports the following languages: 207 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' 208 | # 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr' 209 | #html_search_language = 'en' 210 | 211 | # A dictionary with options for the search language support, empty by default. 212 | # Now only 'ja' uses this config value 213 | #html_search_options = {'type': 'default'} 214 | 215 | # The name of a javascript file (relative to the configuration directory) that 216 | # implements a search results scorer. If empty, the default will be used. 217 | #html_search_scorer = 'scorer.js' 218 | 219 | # Output file base name for HTML help builder. 220 | htmlhelp_basename = 'txacmedoc' 221 | 222 | # -- Options for LaTeX output --------------------------------------------- 223 | 224 | latex_elements = { 225 | # The paper size ('letterpaper' or 'a4paper'). 226 | #'papersize': 'letterpaper', 227 | 228 | # The font size ('10pt', '11pt' or '12pt'). 229 | #'pointsize': '10pt', 230 | 231 | # Additional stuff for the LaTeX preamble. 232 | #'preamble': '', 233 | 234 | # Latex figure (float) alignment 235 | #'figure_align': 'htbp', 236 | } 237 | 238 | # Grouping the document tree into LaTeX files. List of tuples 239 | # (source start file, target name, title, 240 | # author, documentclass [howto, manual, or own class]). 241 | latex_documents = [ 242 | (master_doc, 'txacme.tex', u'txacme Documentation', 243 | u'Tristan Seligmann', 'manual'), 244 | ] 245 | 246 | # The name of an image file (relative to this directory) to place at the top of 247 | # the title page. 248 | #latex_logo = None 249 | 250 | # For "manual" documents, if this is true, then toplevel headings are parts, 251 | # not chapters. 252 | #latex_use_parts = False 253 | 254 | # If true, show page references after internal links. 255 | #latex_show_pagerefs = False 256 | 257 | # If true, show URL addresses after external links. 258 | #latex_show_urls = False 259 | 260 | # Documents to append as an appendix to all manuals. 261 | #latex_appendices = [] 262 | 263 | # If false, no module index is generated. 264 | #latex_domain_indices = True 265 | 266 | 267 | # -- Options for manual page output --------------------------------------- 268 | 269 | # One entry per manual page. List of tuples 270 | # (source start file, name, description, authors, manual section). 271 | man_pages = [ 272 | (master_doc, 'txacme', u'txacme Documentation', 273 | [author], 1) 274 | ] 275 | 276 | # If true, show URL addresses after external links. 277 | #man_show_urls = False 278 | 279 | 280 | # -- Options for Texinfo output ------------------------------------------- 281 | 282 | # Grouping the document tree into Texinfo files. List of tuples 283 | # (source start file, target name, title, author, 284 | # dir menu entry, description, category) 285 | texinfo_documents = [ 286 | (master_doc, 'txacme', u'txacme Documentation', 287 | author, 'txacme', 'One line description of project.', 288 | 'Miscellaneous'), 289 | ] 290 | 291 | # Documents to append as an appendix to all manuals. 292 | #texinfo_appendices = [] 293 | 294 | # If false, no module index is generated. 295 | #texinfo_domain_indices = True 296 | 297 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 298 | #texinfo_show_urls = 'footnote' 299 | 300 | # If true, do not generate a @detailmenu in the "Top" node's menu. 301 | #texinfo_no_detailmenu = False 302 | 303 | 304 | # Example configuration for intersphinx: refer to the Python standard library. 305 | intersphinx_mapping = { 306 | 'python': ('https://docs.python.org/3/', 307 | (None, 'python-objects.inv')), 308 | 'acme': ('https://acme-python.readthedocs.io/en/latest/', 309 | (None, 'acme-objects.inv')), 310 | 'jose': ('https://josepy.readthedocs.io/en/latest/', 311 | (None, 'jose-objects.inv')), 312 | 'twisted': ('https://twisted.readthedocs.io/en/latest/', 313 | (None, 'twisted-objects.inv')), 314 | 'cryptography': ('https://cryptography.io/en/latest/', 315 | (None, 'cryptography-objects.inv')), 316 | 'pem': ('https://pem.readthedocs.io/en/stable/', 317 | (None, 'pem-objects.inv')), 318 | } 319 | 320 | nitpick_ignore = [('py:class', 'testtools.testcase.TestCase')] 321 | 322 | 323 | import inspect 324 | from os.path import relpath, dirname 325 | 326 | def linkcode_resolve(domain, info): 327 | """ 328 | Determine the URL corresponding to Python object 329 | """ 330 | if domain != 'py': 331 | return None 332 | modname = info['module'] 333 | fullname = info['fullname'] 334 | submod = sys.modules.get(modname) 335 | if submod is None: 336 | return None 337 | obj = submod 338 | for part in fullname.split('.'): 339 | try: 340 | obj = getattr(obj, part) 341 | except: 342 | return None 343 | try: 344 | fn = inspect.getsourcefile(obj) 345 | except: 346 | fn = None 347 | if not fn: 348 | return None 349 | try: 350 | source, lineno = inspect.findsource(obj) 351 | except: 352 | lineno = None 353 | if lineno: 354 | linespec = "#L%d" % (lineno + 1) 355 | else: 356 | linespec = "" 357 | fn = relpath(fn, start='..') 358 | return "https://github.com/mithrandi/txacme/blob/%s/%s%s" % ( 359 | txacme_version_info['full-revisionid'], fn, linespec) 360 | 361 | def run_apidoc(_): 362 | modules = ['../src/txacme'] 363 | for module in modules: 364 | cur_dir = os.path.abspath(os.path.dirname(__file__)) 365 | output_path = os.path.join(cur_dir, 'api') 366 | cmd_path = 'sphinx-apidoc' 367 | cmd_path = os.path.abspath( 368 | os.path.join(sys.prefix, 'bin', 'sphinx-apidoc') 369 | ) 370 | subprocess.check_call( 371 | [cmd_path, '-e', '-o', output_path, module, '--force'], 372 | env={'SPHINX_APIDOC_OPTIONS': 'members'}) 373 | 374 | def setup(app): 375 | app.connect('builder-inited', run_apidoc) 376 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | txacme: A Twisted implementation of the ACME protocol 2 | ===================================================== 3 | 4 | .. include:: ../README.rst 5 | :start-after: teaser-begin 6 | 7 | 8 | Contents 9 | ======== 10 | 11 | .. toctree:: 12 | :maxdepth: 2 13 | 14 | using 15 | certs-dir 16 | api-stability 17 | changelog 18 | API documentation 19 | 20 | 21 | Indices and tables 22 | ================== 23 | 24 | * :ref:`genindex` 25 | * :ref:`modindex` 26 | * :ref:`search` 27 | 28 | -------------------------------------------------------------------------------- /docs/pebble-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "pebble": { 3 | "listenAddress": "0.0.0.0:14000", 4 | "managementListenAddress": "0.0.0.0:15000", 5 | "certificate": ".tox/pebble/certs/localhost/cert.pem", 6 | "privateKey": ".tox/pebble/certs/localhost/key.pem", 7 | "ocspResponderURL": "http://127.0.0.1:4002", 8 | "httpPort": 5002, 9 | "tlsPort": 5001 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /docs/service_example.py: -------------------------------------------------------------------------------- 1 | """ 2 | This wants to be a simple example demonstrating all the txacme service 3 | capabilities. 4 | 5 | Each time it starts, if one is not defined, it will generate a new 6 | private key and register a new account. 7 | 8 | You will need to have a TXACME v2 server available and point the script to 9 | use that server. 10 | 11 | Example usage: 12 | 13 | # Start pebble in a separate process. 14 | $ /tmp/pebble_linux-amd64 --config docs/pebble-config.json 15 | 16 | # Update /etc/hosts to have test.local and www.test.local 17 | # Make sure all python txacme dependencies are installed. 18 | $ python docs/service_example.py \ 19 | test.local,www.test.local \ 20 | https://127.0.0.1:14000/dir 21 | 22 | """ 23 | from __future__ import unicode_literals, print_function 24 | import sys 25 | 26 | import pem 27 | 28 | from cryptography.hazmat.backends import default_backend 29 | from cryptography.hazmat.primitives import serialization 30 | 31 | from eliot import add_destinations 32 | from eliot.parse import Parser 33 | from eliottree import render_tasks 34 | 35 | from josepy.jwa import RS256 36 | from josepy.jwk import JWKRSA 37 | from twisted.internet import reactor, defer, task 38 | from twisted.internet.endpoints import TCP4ServerEndpoint 39 | from twisted.web import http 40 | from twisted.web.resource import Resource 41 | from twisted.web.server import Site 42 | from twisted.python.url import URL 43 | from zope.interface import implementer 44 | 45 | from txacme.client import Client 46 | from txacme.service import AcmeIssuingService 47 | from txacme.interfaces import ICertificateStore, IResponder 48 | from txacme.util import generate_private_key 49 | 50 | # WARNING. THIS DISABLES SSL validation in twisted client. 51 | # Is here to make the example easier to read. 52 | import twisted.internet._sslverify as v 53 | v.platformTrust = lambda: None 54 | 55 | HTTP_O1_PORT = 5002 56 | 57 | # Copy inside an exiting private key and check that account is reused. 58 | # Or leave it empty to have the key automatically generated. 59 | ACCOUNT_KEY_PEM = """ 60 | -----BEGIN RSA PRIVATE KEY----- 61 | MIIEpQIBAAKCAQEAqJc17HS3PftQZzharEnOpdW1eCxJvqHuiciolx6qtu1X3YIa 62 | PlG/e36oL4ENqMekJ/caEISMr0y1OUi6NVvjWisZpJXCg1RHwrSAw8/pYaE8IIrs 63 | ffPd6Y8R/sTSDGVCKFkx5R4e4VRmimfrZlnNPFeAFXvfgKM3ZmavN1KUoaghQktr 64 | /NpmKCzSaBViMv1LpqsXh6xCyRRbT3hbRcxDNK+m5rgq7Xg6XSkV4eKYtZYrGHyU 65 | lydKEmxmrDawk71YRgSsAWGDLro/tsCUMIKPQoz+cQwwaWdbABAHStUPwARCiuHG 66 | wIa2slAckbPOgwzCLL/mt+sBXQnATtIrukci/QIDAQABAoIBAQCI5pExO+349PTr 67 | fMWUljKqU4oS1dPka1ZqqHjOjmaOONla1GU/Kd7WB5nHSYKwBb31fiC6PQiI6T9Y 68 | Dwi2f7F07P7buYjEYFINd8oAN/sJ/oX23xj/hmIzYKx6N5Vh33ADl7p+lSD6VTEX 69 | Px/WcyHH2D34NCjgKqm4C7ZItFRhl/ZAbSs1GNuN3TvlBhUfR/nr3pYxhZ/7cY1k 70 | LozhARbT74Xsa8layl5cs2r2jXQRfBRXF8D1dSxp0zhiI3V1ywmHsTWYtEjofTgN 71 | iJI5e+e5csl/ZlbQVJCx6oIxDumymZ+cwEQN4NB95g2mX2qfmmBlB2uuFaEldCx7 72 | hnKvBc35AoGBANQqaWX1GwgOti279ezfyIELTgT2CBsWqxOAtC+p9Jb5e9Pkvuo0 73 | wreVgf+lcgCQNr972MmNWYtQoFgP8VDfUT6+RdVClmoglAjbUbSlPSXiJ8AGOOqD 74 | XxwB0RhZTdhXUsu9L+QDpDF4+kpPQNEZTUHpL7TLMhvM80c2cgTnmSQPAoGBAMts 75 | FkQrdatIoWY2fUoQNhWe/PhSpSOGpgDE2PcmDhuseeI1dzCzOfvaQqgqI5iVgxrS 76 | AYdDdeXwELhB1hAk8d7jUA8HBG4b7PfHkCmQ9NdYb8YoBOpVilL0OYuSf4kQJYdI 77 | Ody+Tfa6DxnYjrZW24uyQGMUa8ex1MrG/R00Z8wzAoGBAKh4pQjZAIX9aJwYTMez 78 | SzttBp7Z3sXj0iTCZlIS2q2nnbQ8R30iOBwfFAM0FLptyYtzhElHfHsroqdKwYw+ 79 | R/1SiZE2Nso+5E3EGbUgINYcJwRL7JYLi1Jp/ucewrmvXYd6yrR8T70ZG2Y2WHmx 80 | Za+YwtEFKNz6eZNqoE9UuD3xAoGBAJwE3ZsRXiGuBiRYHIYmouS4WTu4X3I8/qtO 81 | Tz5X0LBG/ACUk0Ml444YG9HQ6BZKbhCvC38MLavbEWfRDva4703NOIUeE7bD8l8k 82 | j5xh0ngsGyZ3YTW9v+bZ7BzxkqG0YaQ9sCtvRmq6z4Q6RVLykVa2s42KhxPVf+i6 83 | 8D1rCUVjAoGAc8kja8SUE5uxQZHfBlD7MXS7wNqRsginnI0+gZrmOQLAM2fUTcP9 84 | aLhPkFOG4jTZGg3TwV5XNk+f/nh4Ps+GKYng6MneKzN9+mcZ30T54e+1gd+chz9X 85 | yLwHm1esy/txlB27jQ26/8LabbLRQkFCiXxdJ9W1+TEWoq0YT25Awek= 86 | -----END RSA PRIVATE KEY----- 87 | """.strip() 88 | # Uncomment this to have a new account key generated at each run. 89 | # ACCOUNT_KEY_PEM = '' 90 | 91 | 92 | class EliotTreeDestination: 93 | def __init__(self, out=sys.stdout.write, **opts): 94 | self.out = out 95 | self.opts = opts 96 | self._parser = Parser() 97 | 98 | def __call__(self, message): 99 | tasks, self._parser = self._parser.add(message) 100 | 101 | if tasks: 102 | render_tasks(self.out, tasks, **self.opts) 103 | 104 | 105 | def _get_account_key(): 106 | """ 107 | Return the private key to be used for ACME interaction. 108 | """ 109 | if ACCOUNT_KEY_PEM: 110 | return serialization.load_pem_private_key( 111 | ACCOUNT_KEY_PEM.encode('ascii'), 112 | password=None, 113 | backend=default_backend(), 114 | ) 115 | # We don't have a key...so generate one. 116 | key = generate_private_key('rsa') 117 | account_key = key.private_bytes( 118 | encoding=serialization.Encoding.PEM, 119 | format=serialization.PrivateFormat.TraditionalOpenSSL, 120 | encryption_algorithm=serialization.NoEncryption(), 121 | ) 122 | print('New account key generated:\n%s' % (account_key)) 123 | return key 124 | 125 | 126 | class StaticTextResource(Resource, object): 127 | """ 128 | A resource returning a static page... is a placeholder page. 129 | """ 130 | def __init__(self, content='', content_type='text/plain', code=http.OK): 131 | self._content = content.encode('utf-8') 132 | self._content_type = content_type.encode('ascii') 133 | self._code = code 134 | super(StaticTextResource, self).__init__() 135 | 136 | def getChild(self, name, request): 137 | """ 138 | Called when no other resources are attached. 139 | """ 140 | return self 141 | 142 | def render(self, request): 143 | """ 144 | Return the same content. 145 | """ 146 | request.setHeader(b'Content-Type', self._content_type) 147 | request.setResponseCode(self._code) 148 | return self._content 149 | 150 | 151 | @implementer(IResponder) 152 | class HTTP01Responder(StaticTextResource): 153 | """ 154 | Web resource for ``http-01`` challenge responder. 155 | 156 | Beside the challenge pages, it displays empty pages. 157 | """ 158 | challenge_type = u'http-01' 159 | 160 | def __init__(self): 161 | super(HTTP01Responder, self).__init__('') 162 | # Add a static response to help with connection troubleshooting. 163 | self.putChild(b'test.txt', StaticTextResource('Let\'s Encrypt Ready')) 164 | 165 | def start_responding(self, ignore, challenge, response): 166 | """ 167 | Prepare for the ACME server to validate the challenge. 168 | """ 169 | self.putChild( 170 | challenge.encode('token').encode('utf-8'), 171 | StaticTextResource(response.key_authorization), 172 | ) 173 | 174 | def stop_responding(self, ignore, challenge, ignored): 175 | """ 176 | Remove the child resource once the process is done. 177 | """ 178 | encoded_token = challenge.encode('token').encode('utf-8') 179 | if self.getStaticEntity(encoded_token) is not None: 180 | self.delEntity(encoded_token) 181 | 182 | 183 | def start_http01_server(port=5002): 184 | """ 185 | Start an HTTP server which handles HTTP-01 changeless. 186 | """ 187 | responder = HTTP01Responder() 188 | root = StaticTextResource( 189 | 'Just a test server. Main thing in: ' 190 | '.well-known/acme-challenge/test.txt' 191 | ) 192 | well_known = StaticTextResource('') 193 | root.putChild('.well-known', well_known) 194 | well_known.putChild('acme-challenge', responder) 195 | 196 | endpoint = TCP4ServerEndpoint( 197 | reactor=reactor, 198 | interface='0.0.0.0', 199 | port=port, 200 | ) 201 | deferred = endpoint.listen(Site(root)) 202 | deferred.addCallback(lambda result: (result, responder)) 203 | return deferred 204 | 205 | 206 | @defer.inlineCallbacks 207 | def start_responders(): 208 | port, http01_responder = yield start_http01_server(port=HTTP_O1_PORT) 209 | defer.returnValue([http01_responder]) 210 | 211 | 212 | @implementer(ICertificateStore) 213 | class MemoryStore(object): 214 | """ 215 | A certificate store that keeps certificates in memory only and shows 216 | when a new certificate was added. 217 | """ 218 | def __init__(self, certs=None): 219 | if certs is None: 220 | self._store = {} 221 | else: 222 | self._store = dict(certs) 223 | 224 | # This is a certificate which is expired. 225 | self._store['localhost'] = pem.parse(""" 226 | -----BEGIN CERTIFICATE----- 227 | MIICPzCCAaigAwIBAgIBBzANBgkqhkiG9w0BAQUFADBGMQswCQYDVQQGEwJHQjEP 228 | MA0GA1UEChMGQ2hldmFoMRIwEAYDVQQLEwlDaGV2YWggQ0ExEjAQBgNVBAMTCUNo 229 | ZXZhaCBDQTAeFw0xNjAyMTAyMzE5MDBaFw0xNjA0MTEyMzE5MDBaMDIxCzAJBgNV 230 | BAYTAkdCMQ8wDQYDVQQKEwZDaGV2YWgxEjAQBgNVBAMMCXRlc3RfdXNlcjCBnzAN 231 | BgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAoGWApc109GKTaN5kgdx0jK+6qFx84lgT 232 | UZuTcYAmn4WEMBtV/B3BFgjIlq5ubYCosu56rNnItbH1/a4voYiWdoq2zErABkg5 233 | slEYRx66f7EocFAQwakzl0vxKLMn5X84uefZSPPUvac40KoudJn1Ys+cQSVfNOcm 234 | 8rUNELEi7IUCAwEAAaNRME8wEwYDVR0lBAwwCgYIKwYBBQUHAwIwOAYDVR0fBDEw 235 | LzAtoCugKYYnaHR0cDovL2xvY2FsaG9zdDo4MDgwL3NvbWUtY2hpbGQvY2EuY3Js 236 | MA0GCSqGSIb3DQEBBQUAA4GBADWigcPHP+SF6n7pmAxV4DSt6CBQ+Z8RL7G1f43Z 237 | rW3pcIZkcFhc+2YccGdoiP1DfJhQKyuH+oQgTSp2w3eiNX/t/CaWw4XDHeE0C7kQ 238 | +yMG/FpuVQaZ2uXDqvACGhLCPRkoHjUdi5ZyXzdtqSrm0MqYv48wR8/xV+sUCHTB 239 | 9Ze9 240 | -----END CERTIFICATE----- 241 | """) 242 | 243 | def get(self, server_name): 244 | try: 245 | return defer.succeed(self._store[server_name]) 246 | except KeyError: 247 | return defer.fail() 248 | 249 | def store(self, server_name, pem_objects): 250 | self._store[server_name] = pem_objects 251 | print('Got a new certificate for "%s":\n\n%s' % ( 252 | server_name, pem_objects)) 253 | return defer.succeed(None) 254 | 255 | def as_dict(self): 256 | return defer.succeed(self._store) 257 | 258 | 259 | def on_panic(failure, certificate_name): 260 | """ 261 | Called when (re)issuing of a certificate failes. 262 | """ 263 | print('Failed to get certificate for %s: %s' % ( 264 | certificate_name, failure)) 265 | 266 | 267 | @defer.inlineCallbacks 268 | def get_things_done(): 269 | """ 270 | Here is where the service part is setup and action is done. 271 | """ 272 | responders = yield start_responders() 273 | 274 | store = MemoryStore() 275 | 276 | # We first validate the directory. 277 | account_key = _get_account_key() 278 | try: 279 | client = yield Client.from_url( 280 | reactor, 281 | URL.fromText(acme_url.decode('utf-8')), 282 | key=JWKRSA(key=account_key), 283 | alg=RS256, 284 | ) 285 | except Exception as error: 286 | print('\n\nFailed to connect to ACME directory. %s' % (error,)) 287 | yield reactor.stop() 288 | defer.returnValue(None) 289 | 290 | service = AcmeIssuingService( 291 | email='txacme-test1@twstedmatrix.org,txacme-test2@twstedmatrix.org', 292 | cert_store=store, 293 | client=client, 294 | clock=reactor, 295 | responders=responders, 296 | panic=on_panic, 297 | ) 298 | 299 | # Start the service and wait for it to start. 300 | yield service.start() 301 | 302 | # Wait for the existing certificate from the storage to be available. 303 | yield service.when_certs_valid() 304 | 305 | # Request a SAN ... if passed via command line. 306 | yield service.issue_cert(','.join(requested_domains)) 307 | 308 | yield service.stopService() 309 | 310 | print('That was all the example.') 311 | 312 | 313 | def eb_general_failure(failure): 314 | """ 315 | Called when any operation fails. 316 | """ 317 | print(failure) 318 | 319 | 320 | def show_usage(): 321 | """ 322 | Show the help on how to use the command. 323 | """ 324 | print('Usage: %s REQUSTED_DOMAINS [API_ENDPOINT]\n' % (sys.argv[0],)) 325 | print('REQUSTED_DOMAINS -> comma separated list of domains') 326 | print('It will use the staging server if API_ENDPOINT is not provided.') 327 | 328 | print('\nACME v2 endpoints:') 329 | print('[Production] https://acme-v02.api.letsencrypt.org/directory') 330 | print('[Staging] https://acme-staging-v02.api.letsencrypt.org/directory') 331 | sys.exit(1) 332 | 333 | 334 | for arg in sys.argv: 335 | if arg.lower().strip() in ['-h', '--help']: 336 | show_usage() 337 | 338 | if len(sys.argv) < 2: 339 | show_usage() 340 | 341 | try: 342 | acme_url = sys.argv[2] 343 | except IndexError: 344 | # Fallback to Let's Encrypt staging server. 345 | acme_url = 'https://acme-staging-v02.api.letsencrypt.org/directory' 346 | 347 | requested_domains = [d.strip().decode('utf-8') for d in sys.argv[1].split(',')] 348 | 349 | print('\n\n') 350 | print('-' * 70) 351 | print('Using ACME at %s' % (acme_url,)) 352 | print('Managing a single certificate for %s' % (requested_domains,)) 353 | print( 354 | 'HTTP-01 responser at ' 355 | 'http://localhost:%s/.well-known/acme-challenge/test.txt' % (HTTP_O1_PORT,) 356 | ) 357 | print('-' * 70) 358 | print('\n\n') 359 | 360 | add_destinations(EliotTreeDestination( 361 | colorize=True, colorize_tree=True, human_readable=True)) 362 | 363 | 364 | def main(reactor): 365 | d = get_things_done() 366 | d.addErrback(eb_general_failure) 367 | return d 368 | 369 | 370 | if __name__ == '__main__': 371 | task.react(main) 372 | -------------------------------------------------------------------------------- /docs/using.rst: -------------------------------------------------------------------------------- 1 | Using txacme 2 | ============ 3 | 4 | There are several possible ways to make use of txacme: 5 | 6 | * An issuing service for keeping certificates in a certificate store up to date; 7 | 8 | * Lowest-level public API for interacting with an ACME server. 9 | 10 | 11 | Issuing service 12 | --------------- 13 | 14 | The issuing service takes care of certificate issuance, renewal and retry on 15 | errors. 16 | 17 | The service is linked to an ICertificateStore which takes care of storing 18 | the issued certificates. 19 | The certificate store can be used to receive hooks get an update whenever a 20 | new certificate was issued. 21 | 22 | .. autoclass:: txacme.service.AcmeIssuingService 23 | :noindex: 24 | :members: 25 | 26 | The `~txacme.interfaces.ICertificateStore` and `~txacme.interfaces.IResponder` 27 | interfaces are the main extension points for using the issuing service 28 | directly. For example, a custom implementation of 29 | `~txacme.interfaces.ICertificateStore` might manage the certificate 30 | configuration of a cloud load balancer, implementing the ``dns-01`` challenge 31 | type by modifying DNS entries in the cloud DNS configuration. 32 | 33 | 34 | Certificate storage 35 | ------------------- 36 | 37 | .. autoclass:: txacme.interface.ICertificateStore 38 | :noindex: 39 | :members: -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.towncrier] 2 | package = 'txacme' 3 | package_dir = 'src/' 4 | filename = 'docs/changelog.rst' 5 | -------------------------------------------------------------------------------- /requirements-doc.txt: -------------------------------------------------------------------------------- 1 | .[test,libcloud] 2 | sphinx 3 | sphinx_rtd_theme 4 | repoze.sphinx.autointerface>=0.8 5 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 3 | 4 | [isort] 5 | default_section=THIRDPARTY 6 | known_first_party=txacme 7 | multi_line_output=4 8 | lines_after_imports=2 9 | balanced_wrapping=True 10 | order_by_type=False 11 | 12 | [flake8] 13 | exclude=src/txacme/_version.py,src/txacme/interfaces.py 14 | ignore_names=setUp,_setUp,tearDown,startService,stopService 15 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import codecs 3 | from setuptools import setup, find_packages 4 | 5 | HERE = os.path.abspath(os.path.dirname(__file__)) 6 | 7 | 8 | def read(*parts): 9 | with codecs.open(os.path.join(HERE, *parts), 'rb', 'utf-8') as f: 10 | return f.read() 11 | 12 | 13 | setup( 14 | version='2.0.0.dev0', 15 | name='txacme', 16 | description='ACME protocol implementation for Twisted', 17 | license='Expat', 18 | url='https://github.com/mithrandi/txacme', 19 | author='Tristan Seligmann', 20 | author_email='mithrandi@mithrandi.net', 21 | maintainer='Tristan Seligmann', 22 | maintainer_email='mithrandi@mithrandi.net', 23 | long_description=read('README.rst'), 24 | packages=find_packages(where='src'), 25 | package_dir={'': 'src'}, 26 | zip_safe=True, 27 | classifiers=[ 28 | 'Development Status :: 3 - Alpha', 29 | 'Intended Audience :: Developers', 30 | 'Natural Language :: English', 31 | 'License :: OSI Approved :: MIT License', 32 | 'Operating System :: OS Independent', 33 | 'Programming Language :: Python', 34 | 'Programming Language :: Python :: 2', 35 | 'Programming Language :: Python :: 2.7', 36 | 'Programming Language :: Python :: 3', 37 | 'Programming Language :: Python :: 3.4', 38 | 'Programming Language :: Python :: 3.5', 39 | 'Programming Language :: Python :: 3.6', 40 | 'Programming Language :: Python :: 3.7', 41 | 'Programming Language :: Python :: Implementation :: CPython', 42 | 'Programming Language :: Python :: Implementation :: PyPy', 43 | 'Topic :: Software Development :: Libraries :: Python Modules', 44 | ], 45 | install_requires=[ 46 | 'acme>=1.0.0', 47 | 'attrs>=17.4.0', 48 | 'eliot>=0.8.0', 49 | 'josepy', 50 | 'pem>=16.1.0', 51 | 'treq>=15.1.0', 52 | 'twisted[tls]>=16.2.0', 53 | 'txsni', 54 | 'pyopenssl>=17.1.0', 55 | ], 56 | extras_require={ 57 | 'libcloud': [ 58 | 'apache-libcloud', 59 | ], 60 | 'dev': [ 61 | 'coverage', 62 | 'eliot-tree', 63 | ], 64 | }, 65 | ) 66 | -------------------------------------------------------------------------------- /src/integration/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twisted/txacme/ac382ff1037cba8fb5ea6c90813eccfc69e53d36/src/integration/__init__.py -------------------------------------------------------------------------------- /src/integration/test_client.py: -------------------------------------------------------------------------------- 1 | """ 2 | Integration tests for :mod:`acme.client`. 3 | """ 4 | from __future__ import print_function 5 | 6 | from functools import partial 7 | from os import getenv 8 | 9 | from josepy.jwk import JWKRSA 10 | from acme.messages import NewRegistration, STATUS_PENDING 11 | from cryptography.hazmat.primitives import serialization 12 | from eliot import start_action 13 | from eliot.twisted import DeferredContext 14 | from twisted.internet import reactor 15 | from twisted.internet.defer import succeed 16 | from twisted.internet.endpoints import serverFromString 17 | from twisted.python.filepath import FilePath 18 | from twisted.trial.unittest import TestCase 19 | from twisted.web.resource import Resource 20 | from twisted.web.server import Site 21 | from txsni.snimap import SNIMap 22 | from txsni.tlsendpoint import TLSEndpoint 23 | 24 | from txacme.client import ( 25 | answer_challenge, Client, fqdn_identifier, poll_until_valid) 26 | from txacme.messages import CertificateRequest 27 | from txacme.testing import FakeClient, NullResponder 28 | from txacme.urls import LETSENCRYPT_STAGING_DIRECTORY 29 | from txacme.util import csr_for_names, generate_private_key, tap 30 | 31 | 32 | try: 33 | from txacme.challenges import LibcloudDNSResponder 34 | except ImportError: 35 | pass 36 | 37 | 38 | class ClientTestsMixin(object): 39 | """ 40 | Integration tests for the ACME client. 41 | """ 42 | def _test_create_client(self): 43 | with start_action(action_type=u'integration:create_client').context(): 44 | self.key = JWKRSA(key=generate_private_key('rsa')) 45 | return ( 46 | DeferredContext(self._create_client(self.key)) 47 | .addActionFinish()) 48 | 49 | def _test_register(self, new_reg=None): 50 | with start_action(action_type=u'integration:register').context(): 51 | return ( 52 | DeferredContext(self.client.register(new_reg)) 53 | .addActionFinish()) 54 | 55 | def _test_agree_to_tos(self, reg): 56 | with start_action(action_type=u'integration:agree_to_tos').context(): 57 | return ( 58 | DeferredContext(self.client.agree_to_tos(reg)) 59 | .addActionFinish()) 60 | 61 | def _test_request_challenges(self, host): 62 | action = start_action( 63 | action_type=u'integration:request_challenges', 64 | host=host) 65 | with action.context(): 66 | return ( 67 | DeferredContext( 68 | self.client.request_challenges(fqdn_identifier(host))) 69 | .addActionFinish()) 70 | 71 | def _test_poll_pending(self, auth): 72 | action = start_action(action_type=u'integration:poll_pending') 73 | with action.context(): 74 | return ( 75 | DeferredContext(self.client.poll(auth)) 76 | .addCallback( 77 | lambda auth: 78 | self.assertEqual(auth[0].body.status, STATUS_PENDING)) 79 | .addActionFinish()) 80 | 81 | def _test_answer_challenge(self, responder): 82 | action = start_action(action_type=u'integration:answer_challenge') 83 | with action.context(): 84 | self.responder = responder 85 | return ( 86 | DeferredContext( 87 | answer_challenge( 88 | self.authzr, self.client, [responder])) 89 | .addActionFinish()) 90 | 91 | def _test_poll(self, auth): 92 | action = start_action(action_type=u'integration:poll') 93 | with action.context(): 94 | return ( 95 | DeferredContext(poll_until_valid(auth, reactor, self.client)) 96 | .addActionFinish()) 97 | 98 | def _test_issue(self, name): 99 | def got_cert(certr): 100 | key_bytes = self.issued_key.private_bytes( 101 | encoding=serialization.Encoding.PEM, 102 | format=serialization.PrivateFormat.TraditionalOpenSSL, 103 | encryption_algorithm=serialization.NoEncryption()) 104 | FilePath('issued.crt').setContent(certr.body) 105 | FilePath('issued.key').setContent(key_bytes) 106 | return certr 107 | 108 | action = start_action(action_type=u'integration:issue') 109 | with action.context(): 110 | self.issued_key = generate_private_key('rsa') 111 | csr = csr_for_names([name], self.issued_key) 112 | return ( 113 | DeferredContext( 114 | self.client.request_issuance(CertificateRequest(csr=csr))) 115 | .addCallback(got_cert) 116 | .addActionFinish()) 117 | 118 | def _test_chain(self, certr): 119 | action = start_action(action_type=u'integration:chain') 120 | with action.context(): 121 | return ( 122 | DeferredContext(self.client.fetch_chain(certr)) 123 | .addActionFinish()) 124 | 125 | def _test_registration(self): 126 | return ( 127 | DeferredContext(self._test_create_client()) 128 | .addCallback(partial(setattr, self, 'client')) 129 | .addCallback(lambda _: self._test_register()) 130 | .addCallback(tap( 131 | lambda reg1: self.assertEqual(reg1.body.contact, ()))) 132 | .addCallback(tap( 133 | lambda reg1: 134 | self._test_register( 135 | NewRegistration.from_data(email=u'example@example.com')) 136 | .addCallback(tap( 137 | lambda reg2: self.assertEqual(reg1.uri, reg2.uri))) 138 | .addCallback(lambda reg2: self.assertEqual( 139 | reg2.body.contact, (u'mailto:example@example.com',))))) 140 | .addCallback(self._test_agree_to_tos) 141 | .addCallback( 142 | lambda _: self._test_request_challenges(self.HOST)) 143 | .addCallback(partial(setattr, self, 'authzr')) 144 | .addCallback(lambda _: self._create_responder()) 145 | .addCallback(tap(lambda _: self._test_poll_pending(self.authzr))) 146 | .addCallback(self._test_answer_challenge) 147 | .addCallback(tap(lambda _: self._test_poll(self.authzr))) 148 | .addCallback(lambda stop_responding: stop_responding()) 149 | .addCallback(lambda _: self._test_issue(self.HOST)) 150 | .addCallback(self._test_chain) 151 | .addActionFinish()) 152 | 153 | def test_issuing(self): 154 | action = start_action(action_type=u'integration') 155 | with action.context(): 156 | return self._test_registration() 157 | 158 | 159 | def _getenv(name, default=None): 160 | """ 161 | Sigh. 162 | """ 163 | value = getenv(name) 164 | if value is None: 165 | return default 166 | return value 167 | 168 | 169 | class LetsEncryptStagingLibcloudTests(ClientTestsMixin, TestCase): 170 | """ 171 | Tests using the real ACME client against the Let's Encrypt staging 172 | environment, and the dns-01 challenge. 173 | 174 | You must set $ACME_HOST to a hostname that will be used for the challenge, 175 | and $LIBCLOUD_PROVIDER, $LIBCLOUD_USERNAME, $LIBCLOUD_PASSWORD, and 176 | $LIBCLOUD_ZONE to the appropriate values for the DNS provider to complete 177 | the challenge with. 178 | """ 179 | HOST = _getenv(u'ACME_HOST') 180 | PROVIDER = _getenv(u'LIBCLOUD_PROVIDER') 181 | USERNAME = _getenv(u'LIBCLOUD_USERNAME') 182 | PASSWORD = _getenv(u'LIBCLOUD_PASSWORD') 183 | ZONE = _getenv(u'LIBCLOUD_ZONE') 184 | 185 | if None in (HOST, PROVIDER, USERNAME, PASSWORD): 186 | skip = 'Must provide $ACME_HOST and $LIBCLOUD_*' 187 | 188 | def _create_client(self, key): 189 | return Client.from_url(reactor, LETSENCRYPT_STAGING_DIRECTORY, key=key) 190 | 191 | def _create_responder(self): 192 | with start_action(action_type=u'integration:create_responder'): 193 | return LibcloudDNSResponder.create( 194 | reactor, 195 | self.PROVIDER, 196 | self.USERNAME, 197 | self.PASSWORD, 198 | self.ZONE) 199 | 200 | 201 | class FakeClientTests(ClientTestsMixin, TestCase): 202 | """ 203 | Tests against our verified fake. 204 | """ 205 | HOST = u'example.com' 206 | 207 | def _create_client(self, key): 208 | return succeed(FakeClient(key, reactor)) 209 | 210 | def _create_responder(self): 211 | return succeed(NullResponder(u'http-01')) 212 | 213 | 214 | __all__ = ['FakeClientTests'] 215 | -------------------------------------------------------------------------------- /src/txacme/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | from ._version import get_versions 3 | __version__ = get_versions()['version'] 4 | del get_versions 5 | -------------------------------------------------------------------------------- /src/txacme/_version.py: -------------------------------------------------------------------------------- 1 | 2 | # This file helps to compute a version number in source trees obtained from 3 | # git-archive tarball (such as those provided by githubs download-from-tag 4 | # feature). Distribution tarballs (built by setup.py sdist) and build 5 | # directories (produced by setup.py build) will contain a much shorter file 6 | # that just contains the computed version number. 7 | 8 | # This file is released into the public domain. Generated by 9 | # versioneer-0.18 (https://github.com/warner/python-versioneer) 10 | 11 | """Git implementation of _version.py.""" 12 | 13 | import errno 14 | import os 15 | import re 16 | import subprocess 17 | import sys 18 | 19 | 20 | def get_keywords(): 21 | """Get the keywords needed to look up the version information.""" 22 | # these strings will be replaced by git during git-archive. 23 | # setup.py/versioneer.py will grep for the variable names, so they must 24 | # each be defined on a line of their own. _version.py will just call 25 | # get_keywords(). 26 | git_refnames = " (HEAD -> master)" 27 | git_full = "ac382ff1037cba8fb5ea6c90813eccfc69e53d36" 28 | git_date = "2022-04-12 20:09:43 +0100" 29 | keywords = {"refnames": git_refnames, "full": git_full, "date": git_date} 30 | return keywords 31 | 32 | 33 | class VersioneerConfig: 34 | """Container for Versioneer configuration parameters.""" 35 | 36 | 37 | def get_config(): 38 | """Create, populate and return the VersioneerConfig() object.""" 39 | # these strings are filled in when 'setup.py versioneer' creates 40 | # _version.py 41 | cfg = VersioneerConfig() 42 | cfg.VCS = "git" 43 | cfg.style = "pep440" 44 | cfg.tag_prefix = "" 45 | cfg.parentdir_prefix = "txacme-" 46 | cfg.versionfile_source = "src/txacme/_version.py" 47 | cfg.verbose = False 48 | return cfg 49 | 50 | 51 | class NotThisMethod(Exception): 52 | """Exception raised if a method is not valid for the current scenario.""" 53 | 54 | 55 | LONG_VERSION_PY = {} 56 | HANDLERS = {} 57 | 58 | 59 | def register_vcs_handler(vcs, method): # decorator 60 | """Decorator to mark a method as the handler for a particular VCS.""" 61 | def decorate(f): 62 | """Store f in HANDLERS[vcs][method].""" 63 | if vcs not in HANDLERS: 64 | HANDLERS[vcs] = {} 65 | HANDLERS[vcs][method] = f 66 | return f 67 | return decorate 68 | 69 | 70 | def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, 71 | env=None): 72 | """Call the given command(s).""" 73 | assert isinstance(commands, list) 74 | p = None 75 | for c in commands: 76 | try: 77 | dispcmd = str([c] + args) 78 | # remember shell=False, so use git.cmd on windows, not just git 79 | p = subprocess.Popen([c] + args, cwd=cwd, env=env, 80 | stdout=subprocess.PIPE, 81 | stderr=(subprocess.PIPE if hide_stderr 82 | else None)) 83 | break 84 | except EnvironmentError: 85 | e = sys.exc_info()[1] 86 | if e.errno == errno.ENOENT: 87 | continue 88 | if verbose: 89 | print("unable to run %s" % dispcmd) 90 | print(e) 91 | return None, None 92 | else: 93 | if verbose: 94 | print("unable to find command, tried %s" % (commands,)) 95 | return None, None 96 | stdout = p.communicate()[0].strip() 97 | if sys.version_info[0] >= 3: 98 | stdout = stdout.decode() 99 | if p.returncode != 0: 100 | if verbose: 101 | print("unable to run %s (error)" % dispcmd) 102 | print("stdout was %s" % stdout) 103 | return None, p.returncode 104 | return stdout, p.returncode 105 | 106 | 107 | def versions_from_parentdir(parentdir_prefix, root, verbose): 108 | """Try to determine the version from the parent directory name. 109 | 110 | Source tarballs conventionally unpack into a directory that includes both 111 | the project name and a version string. We will also support searching up 112 | two directory levels for an appropriately named parent directory 113 | """ 114 | rootdirs = [] 115 | 116 | for i in range(3): 117 | dirname = os.path.basename(root) 118 | if dirname.startswith(parentdir_prefix): 119 | return {"version": dirname[len(parentdir_prefix):], 120 | "full-revisionid": None, 121 | "dirty": False, "error": None, "date": None} 122 | else: 123 | rootdirs.append(root) 124 | root = os.path.dirname(root) # up a level 125 | 126 | if verbose: 127 | print("Tried directories %s but none started with prefix %s" % 128 | (str(rootdirs), parentdir_prefix)) 129 | raise NotThisMethod("rootdir doesn't start with parentdir_prefix") 130 | 131 | 132 | @register_vcs_handler("git", "get_keywords") 133 | def git_get_keywords(versionfile_abs): 134 | """Extract version information from the given file.""" 135 | # the code embedded in _version.py can just fetch the value of these 136 | # keywords. When used from setup.py, we don't want to import _version.py, 137 | # so we do it with a regexp instead. This function is not used from 138 | # _version.py. 139 | keywords = {} 140 | try: 141 | f = open(versionfile_abs, "r") 142 | for line in f.readlines(): 143 | if line.strip().startswith("git_refnames ="): 144 | mo = re.search(r'=\s*"(.*)"', line) 145 | if mo: 146 | keywords["refnames"] = mo.group(1) 147 | if line.strip().startswith("git_full ="): 148 | mo = re.search(r'=\s*"(.*)"', line) 149 | if mo: 150 | keywords["full"] = mo.group(1) 151 | if line.strip().startswith("git_date ="): 152 | mo = re.search(r'=\s*"(.*)"', line) 153 | if mo: 154 | keywords["date"] = mo.group(1) 155 | f.close() 156 | except EnvironmentError: 157 | pass 158 | return keywords 159 | 160 | 161 | @register_vcs_handler("git", "keywords") 162 | def git_versions_from_keywords(keywords, tag_prefix, verbose): 163 | """Get version information from git keywords.""" 164 | if not keywords: 165 | raise NotThisMethod("no keywords at all, weird") 166 | date = keywords.get("date") 167 | if date is not None: 168 | # git-2.2.0 added "%cI", which expands to an ISO-8601 -compliant 169 | # datestamp. However we prefer "%ci" (which expands to an "ISO-8601 170 | # -like" string, which we must then edit to make compliant), because 171 | # it's been around since git-1.5.3, and it's too difficult to 172 | # discover which version we're using, or to work around using an 173 | # older one. 174 | date = date.strip().replace(" ", "T", 1).replace(" ", "", 1) 175 | refnames = keywords["refnames"].strip() 176 | if refnames.startswith("$Format"): 177 | if verbose: 178 | print("keywords are unexpanded, not using") 179 | raise NotThisMethod("unexpanded keywords, not a git-archive tarball") 180 | refs = set([r.strip() for r in refnames.strip("()").split(",")]) 181 | # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of 182 | # just "foo-1.0". If we see a "tag: " prefix, prefer those. 183 | TAG = "tag: " 184 | tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)]) 185 | if not tags: 186 | # Either we're using git < 1.8.3, or there really are no tags. We use 187 | # a heuristic: assume all version tags have a digit. The old git %d 188 | # expansion behaves like git log --decorate=short and strips out the 189 | # refs/heads/ and refs/tags/ prefixes that would let us distinguish 190 | # between branches and tags. By ignoring refnames without digits, we 191 | # filter out many common branch names like "release" and 192 | # "stabilization", as well as "HEAD" and "master". 193 | tags = set([r for r in refs if re.search(r'\d', r)]) 194 | if verbose: 195 | print("discarding '%s', no digits" % ",".join(refs - tags)) 196 | if verbose: 197 | print("likely tags: %s" % ",".join(sorted(tags))) 198 | for ref in sorted(tags): 199 | # sorting will prefer e.g. "2.0" over "2.0rc1" 200 | if ref.startswith(tag_prefix): 201 | r = ref[len(tag_prefix):] 202 | if verbose: 203 | print("picking %s" % r) 204 | return {"version": r, 205 | "full-revisionid": keywords["full"].strip(), 206 | "dirty": False, "error": None, 207 | "date": date} 208 | # no suitable tags, so version is "0+unknown", but full hex is still there 209 | if verbose: 210 | print("no suitable tags, using unknown + full revision id") 211 | return {"version": "0+unknown", 212 | "full-revisionid": keywords["full"].strip(), 213 | "dirty": False, "error": "no suitable tags", "date": None} 214 | 215 | 216 | @register_vcs_handler("git", "pieces_from_vcs") 217 | def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): 218 | """Get version from 'git describe' in the root of the source tree. 219 | 220 | This only gets called if the git-archive 'subst' keywords were *not* 221 | expanded, and _version.py hasn't already been rewritten with a short 222 | version string, meaning we're inside a checked out source tree. 223 | """ 224 | GITS = ["git"] 225 | if sys.platform == "win32": 226 | GITS = ["git.cmd", "git.exe"] 227 | 228 | out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root, 229 | hide_stderr=True) 230 | if rc != 0: 231 | if verbose: 232 | print("Directory %s not under git control" % root) 233 | raise NotThisMethod("'git rev-parse --git-dir' returned error") 234 | 235 | # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty] 236 | # if there isn't one, this yields HEX[-dirty] (no NUM) 237 | describe_out, rc = run_command(GITS, ["describe", "--tags", "--dirty", 238 | "--always", "--long", 239 | "--match", "%s*" % tag_prefix], 240 | cwd=root) 241 | # --long was added in git-1.5.5 242 | if describe_out is None: 243 | raise NotThisMethod("'git describe' failed") 244 | describe_out = describe_out.strip() 245 | full_out, rc = run_command(GITS, ["rev-parse", "HEAD"], cwd=root) 246 | if full_out is None: 247 | raise NotThisMethod("'git rev-parse' failed") 248 | full_out = full_out.strip() 249 | 250 | pieces = {} 251 | pieces["long"] = full_out 252 | pieces["short"] = full_out[:7] # maybe improved later 253 | pieces["error"] = None 254 | 255 | # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty] 256 | # TAG might have hyphens. 257 | git_describe = describe_out 258 | 259 | # look for -dirty suffix 260 | dirty = git_describe.endswith("-dirty") 261 | pieces["dirty"] = dirty 262 | if dirty: 263 | git_describe = git_describe[:git_describe.rindex("-dirty")] 264 | 265 | # now we have TAG-NUM-gHEX or HEX 266 | 267 | if "-" in git_describe: 268 | # TAG-NUM-gHEX 269 | mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe) 270 | if not mo: 271 | # unparseable. Maybe git-describe is misbehaving? 272 | pieces["error"] = ("unable to parse git-describe output: '%s'" 273 | % describe_out) 274 | return pieces 275 | 276 | # tag 277 | full_tag = mo.group(1) 278 | if not full_tag.startswith(tag_prefix): 279 | if verbose: 280 | fmt = "tag '%s' doesn't start with prefix '%s'" 281 | print(fmt % (full_tag, tag_prefix)) 282 | pieces["error"] = ("tag '%s' doesn't start with prefix '%s'" 283 | % (full_tag, tag_prefix)) 284 | return pieces 285 | pieces["closest-tag"] = full_tag[len(tag_prefix):] 286 | 287 | # distance: number of commits since tag 288 | pieces["distance"] = int(mo.group(2)) 289 | 290 | # commit: short hex revision ID 291 | pieces["short"] = mo.group(3) 292 | 293 | else: 294 | # HEX: no tags 295 | pieces["closest-tag"] = None 296 | count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"], 297 | cwd=root) 298 | pieces["distance"] = int(count_out) # total number of commits 299 | 300 | # commit date: see ISO-8601 comment in git_versions_from_keywords() 301 | date = run_command(GITS, ["show", "-s", "--format=%ci", "HEAD"], 302 | cwd=root)[0].strip() 303 | pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1) 304 | 305 | return pieces 306 | 307 | 308 | def plus_or_dot(pieces): 309 | """Return a + if we don't already have one, else return a .""" 310 | if "+" in pieces.get("closest-tag", ""): 311 | return "." 312 | return "+" 313 | 314 | 315 | def render_pep440(pieces): 316 | """Build up version string, with post-release "local version identifier". 317 | 318 | Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you 319 | get a tagged build and then dirty it, you'll get TAG+0.gHEX.dirty 320 | 321 | Exceptions: 322 | 1: no tags. git_describe was just HEX. 0+untagged.DISTANCE.gHEX[.dirty] 323 | """ 324 | if pieces["closest-tag"]: 325 | rendered = pieces["closest-tag"] 326 | if pieces["distance"] or pieces["dirty"]: 327 | rendered += plus_or_dot(pieces) 328 | rendered += "%d.g%s" % (pieces["distance"], pieces["short"]) 329 | if pieces["dirty"]: 330 | rendered += ".dirty" 331 | else: 332 | # exception #1 333 | rendered = "0+untagged.%d.g%s" % (pieces["distance"], 334 | pieces["short"]) 335 | if pieces["dirty"]: 336 | rendered += ".dirty" 337 | return rendered 338 | 339 | 340 | def render_pep440_pre(pieces): 341 | """TAG[.post.devDISTANCE] -- No -dirty. 342 | 343 | Exceptions: 344 | 1: no tags. 0.post.devDISTANCE 345 | """ 346 | if pieces["closest-tag"]: 347 | rendered = pieces["closest-tag"] 348 | if pieces["distance"]: 349 | rendered += ".post.dev%d" % pieces["distance"] 350 | else: 351 | # exception #1 352 | rendered = "0.post.dev%d" % pieces["distance"] 353 | return rendered 354 | 355 | 356 | def render_pep440_post(pieces): 357 | """TAG[.postDISTANCE[.dev0]+gHEX] . 358 | 359 | The ".dev0" means dirty. Note that .dev0 sorts backwards 360 | (a dirty tree will appear "older" than the corresponding clean one), 361 | but you shouldn't be releasing software with -dirty anyways. 362 | 363 | Exceptions: 364 | 1: no tags. 0.postDISTANCE[.dev0] 365 | """ 366 | if pieces["closest-tag"]: 367 | rendered = pieces["closest-tag"] 368 | if pieces["distance"] or pieces["dirty"]: 369 | rendered += ".post%d" % pieces["distance"] 370 | if pieces["dirty"]: 371 | rendered += ".dev0" 372 | rendered += plus_or_dot(pieces) 373 | rendered += "g%s" % pieces["short"] 374 | else: 375 | # exception #1 376 | rendered = "0.post%d" % pieces["distance"] 377 | if pieces["dirty"]: 378 | rendered += ".dev0" 379 | rendered += "+g%s" % pieces["short"] 380 | return rendered 381 | 382 | 383 | def render_pep440_old(pieces): 384 | """TAG[.postDISTANCE[.dev0]] . 385 | 386 | The ".dev0" means dirty. 387 | 388 | Eexceptions: 389 | 1: no tags. 0.postDISTANCE[.dev0] 390 | """ 391 | if pieces["closest-tag"]: 392 | rendered = pieces["closest-tag"] 393 | if pieces["distance"] or pieces["dirty"]: 394 | rendered += ".post%d" % pieces["distance"] 395 | if pieces["dirty"]: 396 | rendered += ".dev0" 397 | else: 398 | # exception #1 399 | rendered = "0.post%d" % pieces["distance"] 400 | if pieces["dirty"]: 401 | rendered += ".dev0" 402 | return rendered 403 | 404 | 405 | def render_git_describe(pieces): 406 | """TAG[-DISTANCE-gHEX][-dirty]. 407 | 408 | Like 'git describe --tags --dirty --always'. 409 | 410 | Exceptions: 411 | 1: no tags. HEX[-dirty] (note: no 'g' prefix) 412 | """ 413 | if pieces["closest-tag"]: 414 | rendered = pieces["closest-tag"] 415 | if pieces["distance"]: 416 | rendered += "-%d-g%s" % (pieces["distance"], pieces["short"]) 417 | else: 418 | # exception #1 419 | rendered = pieces["short"] 420 | if pieces["dirty"]: 421 | rendered += "-dirty" 422 | return rendered 423 | 424 | 425 | def render_git_describe_long(pieces): 426 | """TAG-DISTANCE-gHEX[-dirty]. 427 | 428 | Like 'git describe --tags --dirty --always -long'. 429 | The distance/hash is unconditional. 430 | 431 | Exceptions: 432 | 1: no tags. HEX[-dirty] (note: no 'g' prefix) 433 | """ 434 | if pieces["closest-tag"]: 435 | rendered = pieces["closest-tag"] 436 | rendered += "-%d-g%s" % (pieces["distance"], pieces["short"]) 437 | else: 438 | # exception #1 439 | rendered = pieces["short"] 440 | if pieces["dirty"]: 441 | rendered += "-dirty" 442 | return rendered 443 | 444 | 445 | def render(pieces, style): 446 | """Render the given version pieces into the requested style.""" 447 | if pieces["error"]: 448 | return {"version": "unknown", 449 | "full-revisionid": pieces.get("long"), 450 | "dirty": None, 451 | "error": pieces["error"], 452 | "date": None} 453 | 454 | if not style or style == "default": 455 | style = "pep440" # the default 456 | 457 | if style == "pep440": 458 | rendered = render_pep440(pieces) 459 | elif style == "pep440-pre": 460 | rendered = render_pep440_pre(pieces) 461 | elif style == "pep440-post": 462 | rendered = render_pep440_post(pieces) 463 | elif style == "pep440-old": 464 | rendered = render_pep440_old(pieces) 465 | elif style == "git-describe": 466 | rendered = render_git_describe(pieces) 467 | elif style == "git-describe-long": 468 | rendered = render_git_describe_long(pieces) 469 | else: 470 | raise ValueError("unknown style '%s'" % style) 471 | 472 | return {"version": rendered, "full-revisionid": pieces["long"], 473 | "dirty": pieces["dirty"], "error": None, 474 | "date": pieces.get("date")} 475 | 476 | 477 | def get_versions(): 478 | """Get version information or return default if unable to do so.""" 479 | # I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have 480 | # __file__, we can work backwards from there to the root. Some 481 | # py2exe/bbfreeze/non-CPython implementations don't do __file__, in which 482 | # case we can only use expanded keywords. 483 | 484 | cfg = get_config() 485 | verbose = cfg.verbose 486 | 487 | try: 488 | return git_versions_from_keywords(get_keywords(), cfg.tag_prefix, 489 | verbose) 490 | except NotThisMethod: 491 | pass 492 | 493 | try: 494 | root = os.path.realpath(__file__) 495 | # versionfile_source is the relative path from the top of the source 496 | # tree (where the .git directory might live) to this file. Invert 497 | # this to find the root from __file__. 498 | for i in cfg.versionfile_source.split('/'): 499 | root = os.path.dirname(root) 500 | except NameError: 501 | return {"version": "0+unknown", "full-revisionid": None, 502 | "dirty": None, 503 | "error": "unable to find root of source tree", 504 | "date": None} 505 | 506 | try: 507 | pieces = git_pieces_from_vcs(cfg.tag_prefix, root, verbose) 508 | return render(pieces, cfg.style) 509 | except NotThisMethod: 510 | pass 511 | 512 | try: 513 | if cfg.parentdir_prefix: 514 | return versions_from_parentdir(cfg.parentdir_prefix, root, verbose) 515 | except NotThisMethod: 516 | pass 517 | 518 | return {"version": "0+unknown", "full-revisionid": None, 519 | "dirty": None, 520 | "error": "unable to compute version", "date": None} 521 | -------------------------------------------------------------------------------- /src/txacme/challenges/__init__.py: -------------------------------------------------------------------------------- 1 | from ._http import HTTP01Responder 2 | 3 | 4 | try: 5 | from ._libcloud import LibcloudDNSResponder 6 | except ImportError: 7 | # libcloud may not be installed 8 | pass 9 | 10 | 11 | __all__ = ['HTTP01Responder', 'LibcloudDNSResponder'] 12 | -------------------------------------------------------------------------------- /src/txacme/challenges/_http.py: -------------------------------------------------------------------------------- 1 | """ 2 | ``http-01`` challenge implementation. 3 | """ 4 | from twisted.web.resource import Resource 5 | from twisted.web.static import Data 6 | 7 | from zope.interface import implementer 8 | 9 | from txacme.interfaces import IResponder 10 | 11 | 12 | @implementer(IResponder) 13 | class HTTP01Responder(object): 14 | """ 15 | An ``http-01`` challenge responder for txsni. 16 | """ 17 | challenge_type = u'http-01' 18 | 19 | def __init__(self): 20 | self.resource = Resource() 21 | 22 | def start_responding(self, server_name, challenge, response): 23 | """ 24 | Add the child resource. 25 | """ 26 | self.resource.putChild( 27 | challenge.encode('token').encode('utf-8'), 28 | Data(response.key_authorization.encode('utf-8'), 'text/plain')) 29 | 30 | def stop_responding(self, server_name, challenge, response): 31 | """ 32 | Remove the child resource. 33 | """ 34 | encoded_token = challenge.encode('token').encode('utf-8') 35 | if self.resource.getStaticEntity(encoded_token) is not None: 36 | self.resource.delEntity(encoded_token) 37 | 38 | 39 | __all__ = ['HTTP01Responder'] 40 | -------------------------------------------------------------------------------- /src/txacme/challenges/_libcloud.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import time 3 | from threading import Thread 4 | 5 | import attr 6 | from josepy.b64 import b64encode 7 | from libcloud.dns.providers import get_driver 8 | from twisted._threads import pool 9 | from twisted.internet.defer import Deferred 10 | from twisted.python.failure import Failure 11 | from zope.interface import implementer 12 | 13 | from txacme.errors import NotInZone, ZoneNotFound 14 | from txacme.interfaces import IResponder 15 | from txacme.util import const 16 | 17 | 18 | def _daemon_thread(*a, **kw): 19 | """ 20 | Create a `threading.Thread`, but always set ``daemon``. 21 | """ 22 | thread = Thread(*a, **kw) 23 | thread.daemon = True 24 | return thread 25 | 26 | 27 | def _defer_to_worker(deliver, worker, work, *args, **kwargs): 28 | """ 29 | Run a task in a worker, delivering the result as a ``Deferred`` in the 30 | reactor thread. 31 | """ 32 | deferred = Deferred() 33 | 34 | def wrapped_work(): 35 | try: 36 | result = work(*args, **kwargs) 37 | except BaseException: 38 | f = Failure() 39 | deliver(lambda: deferred.errback(f)) 40 | else: 41 | deliver(lambda: deferred.callback(result)) 42 | worker.do(wrapped_work) 43 | return deferred 44 | 45 | 46 | def _split_zone(server_name, zone_name): 47 | """ 48 | Split the zone portion off from a DNS label. 49 | 50 | :param str server_name: The full DNS label. 51 | :param str zone_name: The zone name suffix. 52 | """ 53 | server_name = server_name.rstrip(u'.') 54 | zone_name = zone_name.rstrip(u'.') 55 | if not (server_name == zone_name or 56 | server_name.endswith(u'.' + zone_name)): 57 | raise NotInZone(server_name=server_name, zone_name=zone_name) 58 | return server_name[:-len(zone_name)].rstrip(u'.') 59 | 60 | 61 | def _get_existing(driver, zone_name, server_name, validation): 62 | """ 63 | Get existing validation records. 64 | """ 65 | if zone_name is None: 66 | zones = sorted( 67 | (z for z 68 | in driver.list_zones() 69 | if server_name.rstrip(u'.') 70 | .endswith(u'.' + z.domain.rstrip(u'.'))), 71 | key=lambda z: len(z.domain), 72 | reverse=True) 73 | if len(zones) == 0: 74 | raise NotInZone(server_name=server_name, zone_name=None) 75 | else: 76 | zones = [ 77 | z for z 78 | in driver.list_zones() 79 | if z.domain == zone_name] 80 | if len(zones) == 0: 81 | raise ZoneNotFound(zone_name=zone_name) 82 | zone = zones[0] 83 | subdomain = _split_zone(server_name, zone.domain) 84 | existing = [ 85 | record for record 86 | in zone.list_records() 87 | if record.name == subdomain and 88 | record.type == 'TXT' and 89 | record.data == validation] 90 | return zone, existing, subdomain 91 | 92 | 93 | def _validation(response): 94 | """ 95 | Get the validation value for a challenge response. 96 | """ 97 | h = hashlib.sha256(response.key_authorization.encode("utf-8")) 98 | return b64encode(h.digest()).decode() 99 | 100 | 101 | @attr.s(hash=False) 102 | @implementer(IResponder) 103 | class LibcloudDNSResponder(object): 104 | """ 105 | A ``dns-01`` challenge responder using libcloud. 106 | 107 | .. warning:: Some libcloud backends are broken with regard to TXT records 108 | at the time of writing; the Route 53 backend, for example. This makes 109 | them unusable with this responder. 110 | 111 | .. note:: This implementation relies on invoking libcloud in a thread, so 112 | may not be entirely production quality. 113 | """ 114 | challenge_type = u'dns-01' 115 | 116 | _reactor = attr.ib() 117 | _thread_pool = attr.ib() 118 | _driver = attr.ib() 119 | zone_name = attr.ib() 120 | settle_delay = attr.ib() 121 | 122 | @classmethod 123 | def create(cls, reactor, driver_name, username, password, zone_name=None, 124 | settle_delay=60.0): 125 | """ 126 | Create a responder. 127 | 128 | :param reactor: The Twisted reactor to use for threading support. 129 | :param str driver_name: The name of the libcloud DNS driver to use. 130 | :param str username: The username to authenticate with (the meaning of 131 | this is driver-specific). 132 | :param str password: The username to authenticate with (the meaning of 133 | this is driver-specific). 134 | :param str zone_name: The zone name to respond in, or ``None`` to 135 | automatically detect zones. Usually auto-detection should be fine, 136 | unless restricting responses to a single specific zone is desired. 137 | :param float settle_delay: The time, in seconds, to allow for the DNS 138 | provider to propagate record changes. 139 | """ 140 | return cls( 141 | reactor=reactor, 142 | thread_pool=pool(const(1), threadFactory=_daemon_thread), 143 | driver=get_driver(driver_name)(username, password), 144 | zone_name=zone_name, 145 | settle_delay=settle_delay) 146 | 147 | def _defer(self, f): 148 | """ 149 | Run a function in our private thread pool. 150 | """ 151 | return _defer_to_worker( 152 | self._reactor.callFromThread, self._thread_pool, f) 153 | 154 | def start_responding(self, server_name, challenge, response): 155 | """ 156 | Install a TXT challenge response record. 157 | """ 158 | validation = _validation(response) 159 | full_name = challenge.validation_domain_name(server_name) 160 | _driver = self._driver 161 | 162 | def _go(): 163 | zone, existing, subdomain = _get_existing( 164 | _driver, self.zone_name, full_name, validation) 165 | if len(existing) == 0: 166 | zone.create_record(name=subdomain, type='TXT', data=validation) 167 | time.sleep(self.settle_delay) 168 | return self._defer(_go) 169 | 170 | def stop_responding(self, server_name, challenge, response): 171 | """ 172 | Remove a TXT challenge response record. 173 | """ 174 | validation = _validation(response) 175 | full_name = challenge.validation_domain_name(server_name) 176 | _driver = self._driver 177 | 178 | def _go(): 179 | zone, existing, subdomain = _get_existing( 180 | _driver, self.zone_name, full_name, validation) 181 | for record in existing: 182 | record.delete() 183 | return self._defer(_go) 184 | 185 | 186 | __all__ = ['LibcloudDNSResponder'] 187 | -------------------------------------------------------------------------------- /src/txacme/client.py: -------------------------------------------------------------------------------- 1 | """ 2 | ACME client API (like :mod:`acme.client`) implementation for Twisted. 3 | 4 | Extracted from RFC 8555 5 | 6 | directory 7 | | 8 | +--> newNonce 9 | | 10 | +----------+----------+-----+-----+------------+ 11 | | | | | | 12 | | | | | | 13 | V V V V V 14 | newAccount newAuthz newOrder revokeCert keyChange 15 | | | | 16 | | | | 17 | V | V 18 | account | order --+--> finalize 19 | | | | 20 | | | +--> cert 21 | | V 22 | +---> authorization 23 | | ^ 24 | | | "up" 25 | V | 26 | challenge 27 | 28 | ACME Resources and Relationships 29 | 30 | The following table illustrates a typical sequence of requests 31 | required to establish a new account with the server, prove control of 32 | an identifier, issue a certificate, and fetch an updated certificate 33 | some time after issuance. The "->" is a mnemonic for a Location 34 | header field pointing to a created resource. 35 | 36 | +-------------------+--------------------------------+--------------+ 37 | | Action | Request | Response | 38 | +-------------------+--------------------------------+--------------+ 39 | |1.Get directory | GET directory | 200 | 40 | | | | | 41 | |2.Get nonce | HEAD newNonce | 200 | 42 | | | | | 43 | |3.Create account | POST newAccount | 201 -> | 44 | | | | account | 45 | | | | | 46 | |4.Submit order | POST newOrder | 201 -> order | 47 | | | | | 48 | |5.Fetch challenges | POST-as-GET order's | 200 | 49 | | | authorization urls | | 50 | | | | | 51 | |6.Respond to | POST authorization challenge | 200 | 52 | | challenges | urls | | 53 | | | | | 54 | |7.Poll for status | POST-as-GET order | 200 | 55 | | | | | 56 | |8.Finalize order | POST order's finalize url | 200 | 57 | | | | | 58 | |9.Poll for status | POST-as-GET order | 200 | 59 | | | | | 60 | |10.Download | POST-as-GET order's | 200 | 61 | | certificate | certificate url | | 62 | +-------------------+--------------------------------+--------------+ 63 | 64 | 1. client = Client.from_url(DIRECTORY_URL) 65 | 2. done as part of Client.from_url() call and automatically for each request 66 | 3. client.start() - creates or updates an account. 67 | 4. order = client.submit_order(new_cert_key, [list,domains]) 68 | 5. list(order.authorizations) - fetch done as part of client.submit_order() 69 | 6. client.check_authoriztion(order.authorizations[0]) and for each 70 | authorization 71 | 7. poll as part of answer_challenge 72 | 8. client.finalize(order) 73 | 9. client.check_order(order) 74 | 10. 75 | 76 | """ 77 | import re 78 | 79 | from acme import errors, messages 80 | from acme.crypto_util import make_csr 81 | from acme.jws import JWS, Header 82 | from acme.messages import ( 83 | STATUS_PENDING, 84 | STATUS_VALID, 85 | STATUS_INVALID, 86 | ) 87 | 88 | import josepy as jose 89 | from josepy.jwa import RS256 90 | from josepy.errors import DeserializationError 91 | 92 | import OpenSSL 93 | from cryptography.hazmat.primitives import serialization 94 | 95 | from eliot.twisted import DeferredContext 96 | from treq import json_content 97 | from treq.client import HTTPClient 98 | from twisted.internet import defer 99 | from twisted.internet.task import deferLater 100 | from twisted.web import http 101 | from twisted.web.client import Agent, HTTPConnectionPool 102 | from twisted.web.http_headers import Headers 103 | 104 | from txacme import __version__ 105 | from txacme.logging import ( 106 | LOG_ACME_ANSWER_CHALLENGE, 107 | LOG_ACME_CONSUME_DIRECTORY, 108 | LOG_ACME_REGISTER, 109 | LOG_HTTP_PARSE_LINKS, 110 | LOG_JWS_ADD_NONCE, 111 | LOG_JWS_CHECK_RESPONSE, 112 | LOG_JWS_GET, 113 | LOG_JWS_GET_NONCE, 114 | LOG_JWS_HEAD, 115 | LOG_JWS_POST, 116 | LOG_JWS_REQUEST, 117 | LOG_JWS_SIGN, 118 | ) 119 | from txacme.util import check_directory_url_type, tap 120 | 121 | _DEFAULT_TIMEOUT = 40 122 | 123 | 124 | # Borrowed from requests, with modifications. 125 | def _parse_header_links(response): 126 | """ 127 | Parse the links from a Link: header field. 128 | 129 | .. todo:: Links with the same relation collide at the moment. 130 | 131 | :param bytes value: The header value. 132 | 133 | :rtype: `dict` 134 | :return: A dictionary of parsed links, keyed by ``rel`` or ``url``. 135 | """ 136 | values = response.headers.getRawHeaders(b'link', [b'']) 137 | value = b','.join(values).decode('ascii') 138 | with LOG_HTTP_PARSE_LINKS(raw_link=value) as action: 139 | links = {} 140 | replace_chars = u' \'"' 141 | for val in re.split(u', *<', value): 142 | try: 143 | url, params = val.split(u';', 1) 144 | except ValueError: 145 | url, params = val, u'' 146 | 147 | link = {} 148 | link[u'url'] = url.strip(u'<> \'"') 149 | for param in params.split(u';'): 150 | try: 151 | key, value = param.split(u'=') 152 | except ValueError: 153 | break 154 | link[key.strip(replace_chars)] = value.strip(replace_chars) 155 | links[link.get(u'rel') or link.get(u'url')] = link 156 | action.add_success_fields(parsed_links=links) 157 | return links 158 | 159 | 160 | def _default_client(jws_client, reactor, key, alg, directory, timeout): 161 | """ 162 | Make a client if we didn't get one. 163 | """ 164 | if jws_client is None: 165 | pool = HTTPConnectionPool(reactor) 166 | agent = Agent(reactor, pool=pool) 167 | jws_d = JWSClient.from_directory(agent, key, alg, directory) 168 | else: 169 | jws_d = defer.succeed(jws_client) 170 | 171 | def set_timeout(jws_client): 172 | jws_client.timeout = timeout 173 | return jws_client 174 | 175 | return jws_d.addCallback(set_timeout) 176 | 177 | 178 | def fqdn_identifier(fqdn): 179 | """ 180 | Construct an identifier from an FQDN. 181 | 182 | Trivial implementation, just saves on typing. 183 | 184 | :param str fqdn: The domain name. 185 | 186 | :return: The identifier. 187 | :rtype: `~acme.messages.Identifier` 188 | """ 189 | return messages.Identifier( 190 | typ=messages.IDENTIFIER_FQDN, value=fqdn) 191 | 192 | 193 | @messages.Directory.register 194 | class Finalize(jose.JSONObjectWithFields): 195 | """ 196 | ACME order finalize request. 197 | 198 | This is here as acme.messages.CertificateRequest does not work with 199 | pebble in --strict mode. 200 | 201 | :ivar josepy.util.ComparableX509 csr: 202 | `OpenSSL.crypto.X509Req` wrapped in `.ComparableX509` 203 | """ 204 | resource_type = 'finalize' 205 | csr = jose.Field('csr', decoder=jose.decode_csr, encoder=jose.encode_csr) 206 | 207 | 208 | class Client(object): 209 | """ 210 | ACME client interface. 211 | 212 | The current implementation does not support multiple parallel requests. 213 | This is due to the nonce handling. 214 | 215 | Should be initialized with 'Client.from_url'. 216 | """ 217 | def __init__(self, directory, reactor, key, jws_client): 218 | self._client = jws_client 219 | self._clock = reactor 220 | self.directory = directory 221 | self.key = key 222 | self._kid = None 223 | 224 | @classmethod 225 | def from_url( 226 | cls, reactor, url, key, alg=RS256, 227 | jws_client=None, timeout=_DEFAULT_TIMEOUT, 228 | ): 229 | """ 230 | Construct a client from an ACME directory at a given URL. 231 | 232 | At construct time, it validates the ACME directory. 233 | 234 | :param url: The ``twisted.python.url.URL`` to fetch the directory from. 235 | See `txacme.urls` for constants for various well-known public 236 | directories. 237 | :param reactor: The Twisted reactor to use. 238 | :param ~josepy.jwk.JWK key: The client key to use. 239 | :param alg: The signing algorithm to use. Needs to be compatible with 240 | the type of key used. 241 | :param JWSClient jws_client: The underlying client to use, or ``None`` 242 | to construct one. 243 | :param int timeout: Number of seconds to wait for an HTTP response 244 | during ACME server interaction. 245 | 246 | :return: The constructed client. 247 | :rtype: Deferred[`Client`] 248 | """ 249 | action = LOG_ACME_CONSUME_DIRECTORY( 250 | url=url, key_type=key.typ, alg=alg.name) 251 | with action.context(): 252 | check_directory_url_type(url) 253 | directory = url.asText() 254 | return ( 255 | DeferredContext(jws_client=_default_client( 256 | jws_client, reactor, key, alg, directory, timeout 257 | )) 258 | .addCallback( 259 | tap(lambda jws_client: 260 | action.add_success_fields(directory=directory))) 261 | .addCallback(lambda jws_client: cls(reactor, key, jws_client)) 262 | .addActionFinish() 263 | ) 264 | 265 | def stop(self): 266 | """ 267 | Stops the client operation. 268 | 269 | This cancels pending operations and does cleanup. 270 | 271 | :return: A deferred which files when the client is stopped. 272 | """ 273 | return self._client.stop() 274 | 275 | def register(self, email=None): 276 | """ 277 | Create a new registration with the ACME server or update 278 | an existing account. 279 | 280 | It should be called before doing any ACME requests. 281 | 282 | :param str: Comma separated contact emails used by the account. 283 | 284 | :return: The registration resource. 285 | :rtype: Deferred[`~acme.messages.RegistrationResource`] 286 | """ 287 | uri = self.directory.newAccount 288 | new_reg = messages.Registration.from_data( 289 | email=email, 290 | terms_of_service_agreed=True, 291 | ) 292 | action = LOG_ACME_REGISTER(registration=new_reg) 293 | with action.context(): 294 | return ( 295 | DeferredContext( 296 | self._client.post(uri, new_reg)) 297 | .addCallback(self._cb_check_existing_account, new_reg) 298 | .addCallback(self._cb_check_registration) 299 | .addCallback( 300 | tap(lambda r: action.add_success_fields(registration=r))) 301 | .addActionFinish()) 302 | 303 | def stop(self): 304 | """ 305 | Stops the client operation. 306 | 307 | This cancels pending operations and does cleanup. 308 | 309 | :return: When operation is done. 310 | :rtype: Deferred[None] 311 | """ 312 | return self._client.stop() 313 | 314 | @classmethod 315 | def _maybe_location(cls, response, uri=None): 316 | """ 317 | Get the Location: if there is one. 318 | """ 319 | location = response.headers.getRawHeaders(b'location', [None])[0] 320 | if location is not None: 321 | return location.decode('ascii') 322 | return uri 323 | 324 | def _cb_check_existing_account(self, response, request): 325 | """ 326 | Get the response from the account registration and see if the 327 | account is already registered and do an update in that case. 328 | """ 329 | if response.code == 200 and request.contact: 330 | # Account already exists and we email address to update. 331 | # I don't know how to remove a contact. 332 | uri = self._maybe_location(response) 333 | deferred = self._client.post(uri, request, kid=uri) 334 | deferred.addCallback(self._cb_parse_registration_response, uri=uri) 335 | return deferred 336 | 337 | return self._cb_parse_registration_response(response) 338 | 339 | def _cb_parse_registration_response(self, response, uri=None): 340 | """ 341 | Parse a new or update registration response from the server. 342 | """ 343 | links = _parse_header_links(response) 344 | terms_of_service = None 345 | if u'terms-of-service' in links: 346 | terms_of_service = links[u'terms-of-service'][u'url'] 347 | return ( 348 | response.json() 349 | .addCallback( 350 | lambda body: 351 | messages.RegistrationResource( 352 | body=messages.Registration.from_json(body), 353 | uri=self._maybe_location(response, uri), 354 | terms_of_service=terms_of_service)) 355 | ) 356 | 357 | def _cb_check_registration(self, regr): 358 | """ 359 | Check that a registration response contains the registration we were 360 | expecting. 361 | """ 362 | if regr.body.key != self.key.public_key(): 363 | # This is a response for another key. 364 | raise errors.UnexpectedUpdate(regr) 365 | 366 | if regr.body.status != 'valid': 367 | raise errors.UnexpectedUpdate(regr) 368 | 369 | self._client.kid = regr.uri 370 | 371 | return regr 372 | 373 | @defer.inlineCallbacks 374 | def submit_order(self, key, names): 375 | """ 376 | Create a new order and return the OrderResource for that order with 377 | all the authorizations resolved. 378 | 379 | It will automatically create a new private key and CSR for the 380 | domain 'names'. 381 | 382 | :param key: Key for the future certificate. 383 | :param list of str names: Sequence of DNS names for which to request 384 | a new certificate. 385 | 386 | :return: The new authorization resource. 387 | :rtype: Deferred[`~acme.messages.Order`] 388 | """ 389 | # certbot helper API needs PEM. 390 | pem_key = key.private_bytes( 391 | encoding=serialization.Encoding.PEM, 392 | format=serialization.PrivateFormat.PKCS8, 393 | encryption_algorithm=serialization.NoEncryption(), 394 | ) 395 | csr_pem = make_csr(pem_key, names) 396 | identifiers = [fqdn_identifier(name) for name in names] 397 | 398 | message = messages.NewOrder(identifiers=identifiers) 399 | response = yield self._client.post(self.directory.newOrder, message) 400 | self._expect_response(response, [http.CREATED]) 401 | 402 | order_uri = self._maybe_location(response) 403 | 404 | authorizations = [] 405 | order_body = yield response.json() 406 | for uri in order_body['authorizations']: 407 | # We do a POST-as-GET 408 | respose = yield self._client.post(uri, obj=None) 409 | self._expect_response(response, [http.CREATED]) 410 | body = yield respose.json() 411 | authorizations.append( 412 | messages.AuthorizationResource( 413 | body=messages.Authorization.from_json(body), 414 | uri=uri, 415 | )) 416 | 417 | order = messages.OrderResource( 418 | body=messages.Order.from_json(order_body), 419 | uri=order_uri, 420 | authorizations=authorizations, 421 | csr_pem=csr_pem, 422 | ) 423 | 424 | # TODO: Not sure if all these sanity checks are required. 425 | for identifier in order.body.identifiers: 426 | if identifier not in identifiers: 427 | raise errors.UnexpectedUpdate(order) 428 | defer.returnValue(order) 429 | 430 | @classmethod 431 | def _expect_response(cls, response, codes): 432 | """ 433 | Ensure we got one of the expected response codes`. 434 | """ 435 | if response.code not in codes: 436 | return _fail_and_consume(response, errors.ClientError( 437 | 'Expected {!r} response but got {!r}'.format( 438 | codes, response.code))) 439 | return response 440 | 441 | def answer_challenge(self, challenge_body, response): 442 | """ 443 | Respond to an authorization challenge. 444 | 445 | This send a POST with the empty object '{}' as the payload. 446 | 447 | :param ~acme.messages.ChallengeBody challenge_body: The challenge being 448 | responded to. 449 | :param ~acme.challenges.ChallengeResponse response: The response to the 450 | challenge. 451 | 452 | :return: The updated challenge resource. 453 | :rtype: Deferred[`~acme.messages.ChallengeResource`] 454 | """ 455 | action = LOG_ACME_ANSWER_CHALLENGE( 456 | challenge_body=challenge_body, response=response) 457 | 458 | if challenge_body.status != STATUS_PENDING: 459 | # We already have an answer. 460 | return challenge_body 461 | 462 | with action.context(): 463 | return ( 464 | DeferredContext( 465 | self._client.post( 466 | challenge_body.uri, jose.JSONObjectWithFields())) 467 | .addCallback(self._parse_challenge) 468 | .addCallback(self._check_challenge, challenge_body) 469 | .addCallback( 470 | tap(lambda c: 471 | action.add_success_fields(challenge_resource=c))) 472 | .addActionFinish()) 473 | 474 | @classmethod 475 | @defer.inlineCallbacks 476 | def _parse_challenge(cls, response): 477 | """ 478 | Parse a challenge resource. 479 | """ 480 | links = _parse_header_links(response) 481 | try: 482 | authzr_uri = links['up']['url'] 483 | except KeyError: 484 | yield _fail_and_consume( 485 | response, errors.ClientError('"up" link missing')) 486 | 487 | body = yield response.json() 488 | defer.returnValue(messages.ChallengeResource( 489 | authzr_uri=authzr_uri, 490 | body=messages.ChallengeBody.from_json(body), 491 | )) 492 | 493 | @classmethod 494 | def _check_challenge(cls, challenge, challenge_body): 495 | """ 496 | Check that the challenge resource we got is the one we expected. 497 | """ 498 | if challenge.uri != challenge_body.uri: 499 | raise errors.UnexpectedUpdate(challenge.uri) 500 | return challenge 501 | 502 | def check_authorization(self, authzz): 503 | """ 504 | Check the status of the authorization. 505 | 506 | Return an updated message.AuthorizationResource. 507 | """ 508 | return self._poll( 509 | authzz.uri, messages.AuthorizationResource, messages.Authorization 510 | ) 511 | 512 | def check_order(self, orderr): 513 | """ 514 | Check the status of the authorization. 515 | 516 | Return an updated message.OrderResource. 517 | """ 518 | return self._poll(orderr.uri, messages.OrderResource, messages.Order) 519 | 520 | @defer.inlineCallbacks 521 | def _poll(self, url, resource_class, body_class,): 522 | """ 523 | Make a POST-as-GET for a resource. 524 | """ 525 | response = yield self._client.post(url, obj=None) 526 | self._expect_response(response, [http.OK]) 527 | body = yield response.json() 528 | defer.returnValue(resource_class( 529 | uri=url, 530 | body=body_class.from_json(body), 531 | )) 532 | 533 | @defer.inlineCallbacks 534 | def finalize(self, order): 535 | """ 536 | Request order finalization. 537 | 538 | Authorizations should have already been completed for all of the names 539 | requested in the order. 540 | 541 | :param ~acme.messages.Order order: The order for which the certificate 542 | is requested. 543 | 544 | :rtype: Deferred[`acme.messages.OrderResource`] 545 | :return: The issued certificate. 546 | """ 547 | csr = OpenSSL.crypto.load_certificate_request( 548 | OpenSSL.crypto.FILETYPE_PEM, order.csr_pem 549 | ) 550 | request = Finalize(csr=jose.ComparableX509(csr)) 551 | response = yield self._client.post( 552 | order.body.finalize, obj=request 553 | ) 554 | self._expect_response(response, [http.OK]) 555 | body = yield response.json() 556 | defer.returnValue(messages.OrderResource( 557 | uri=order.uri, 558 | body=messages.Order.from_json(body), 559 | )) 560 | 561 | @classmethod 562 | def _parse_certificate(cls, response): 563 | """ 564 | Parse a response containing a certificate resource. 565 | """ 566 | links = _parse_header_links(response) 567 | try: 568 | cert_chain_uri = links[u'up'][u'url'] 569 | except KeyError: 570 | cert_chain_uri = None 571 | return ( 572 | response.content() 573 | .addCallback( 574 | lambda body: messages.CertificateResource( 575 | uri=cls._maybe_location(response), 576 | cert_chain_uri=cert_chain_uri, 577 | body=body)) 578 | ) 579 | 580 | @defer.inlineCallbacks 581 | def fetch_certificate(self, url): 582 | """ 583 | Download the certificate for `order`. 584 | 585 | :rtype: acme.messages.CertificateResource 586 | :return: The certificate which was downloaded. 587 | """ 588 | deferred = self._client.post( 589 | url, 590 | content_type=PEM_CHAIN_TYPE, 591 | response_type=PEM_CHAIN_TYPE, 592 | obj=None, 593 | ) 594 | deferred.addCallback(self._parse_certificate) 595 | 596 | result = yield deferred 597 | defer.returnValue(result) 598 | 599 | 600 | def _find_supported_challenge(authzr, responders): 601 | """ 602 | Find a challenge combination that consists of a single challenge that the 603 | responder can satisfy. 604 | 605 | :param ~acme.messages.AuthorizationResource authzr: 606 | The authorization to examine. 607 | 608 | :type responder: List[`~txacme.interfaces.IResponder`] 609 | :param responder: The possible responders to use. 610 | 611 | :raises NoSupportedChallenges: When a suitable challenge combination is not 612 | found. 613 | 614 | :rtype: Tuple[`~txacme.interfaces.IResponder`, 615 | `~acme.messages.ChallengeBody`] 616 | :return: The responder and challenge that were found. 617 | """ 618 | for responder in responders: 619 | r_type = responder.challenge_type 620 | for challenge in authzr.body.challenges: 621 | if r_type == challenge.chall.typ: 622 | return (responder, challenge) 623 | 624 | raise NoSupportedChallenges(authzr) 625 | 626 | 627 | @defer.inlineCallbacks 628 | def answer_challenge(authz, client, responders, clock, timeout=300.0): 629 | """ 630 | Complete an authorization using a responder. 631 | 632 | It waits for the authorization to be completed (as valid or invliad) 633 | for a maximum of 'timeout' seconds. 634 | 635 | pending --------------------+ 636 | | | 637 | Challenge failure | | 638 | or | | 639 | Error | Challenge valid | 640 | +---------+---------+ | 641 | | | | 642 | V V | 643 | invalid valid | 644 | | | 645 | | | 646 | | | 647 | +--------------+--------------+ 648 | | | | 649 | | | | 650 | Server | Client | Time after | 651 | revoke | deactivate | "expires" | 652 | V V V 653 | revoked deactivated expired 654 | 655 | :param ~acme.messages.AuthorizationResource authz: 656 | The authorization answer the challenges for. 657 | :param .Client client: The ACME client. 658 | 659 | :type responders: List[`~txacme.interfaces.IResponder`] 660 | :param responders: A list of responders that can be used to complete the 661 | challenge with. 662 | :param clock: The ``IReactorTime`` implementation to use; usually the 663 | reactor, when not testing. 664 | :param float timeout: Maximum time to poll in seconds, before giving up. 665 | 666 | :raises AuthorizationFailed: If the challenge was not validated. 667 | 668 | :return: A deferred firing when the authorization is verified. 669 | """ 670 | server_name = authz.body.identifier.value 671 | responder, challenge = _find_supported_challenge(authz, responders) 672 | response = challenge.response(client.key) 673 | yield defer.maybeDeferred( 674 | responder.start_responding, server_name, challenge.chall, response) 675 | 676 | resource = yield client.answer_challenge(challenge, response) 677 | 678 | now = clock.seconds() 679 | sleep = 0.5 680 | try: 681 | while True: 682 | resource = yield client.check_authorization(authz) 683 | status = resource.body.status 684 | 685 | if status == STATUS_INVALID: 686 | # No need to wait longer as we got a definitive answer. 687 | raise AuthorizationFailed(resource) 688 | 689 | if status == STATUS_VALID: 690 | # All good. 691 | defer.returnValue(resource) 692 | 693 | if clock.seconds() - now > timeout: 694 | raise AuthorizationFailed(resource) 695 | 696 | yield deferLater(clock, sleep, lambda: None) 697 | sleep += sleep 698 | finally: 699 | yield defer.maybeDeferred( 700 | responder.stop_responding, server_name, challenge.chall, response) 701 | 702 | 703 | @defer.inlineCallbacks 704 | def get_certificate(orderr, client, clock, timeout=300.0): 705 | """ 706 | Finalize the order and return the associated certificate. 707 | 708 | It assumes all authorizations were already validated. 709 | 710 | It waits for the order to be 'valid' for a maximum of 'timeout' seconds.:: 711 | 712 | pending --------------+ 713 | | | 714 | | All authz | 715 | | "valid" | 716 | V | 717 | ready ---------------+ 718 | | | 719 | | Receive | 720 | | finalize | 721 | | request | 722 | V | 723 | processing ------------+ 724 | | | 725 | | Certificate | Error or 726 | | issued | Authorization failure 727 | V V 728 | valid invalid 729 | 730 | :param ~acme.messages.OrderResource orderr: The order to finalize. 731 | :param .Client client: The ACME client. 732 | 733 | :param clock: The ``IReactorTime`` implementation to use; usually the 734 | reactor, when not testing. 735 | :param float timeout: Maximum time to poll in seconds, before giving up. 736 | 737 | :raises ServerError: If a certificate could not be retrieved. 738 | 739 | :return: A deferred firing when the PEM certificate is retrieved. 740 | """ 741 | orderr = yield client.finalize(orderr) 742 | 743 | now = clock.seconds() 744 | sleep = 0.5 745 | 746 | while True: 747 | status = orderr.body.status 748 | 749 | if status == STATUS_VALID: 750 | # All good. 751 | break 752 | 753 | if status == STATUS_INVALID: 754 | raise ServerError('Order is now invalid.') 755 | 756 | if clock.seconds() - now > timeout: 757 | raise ServerError('Timeout while waiting for order finalization.') 758 | 759 | yield deferLater(clock, sleep, lambda: None) 760 | sleep += sleep 761 | 762 | orderr = yield client.check_order(orderr) 763 | 764 | certificate = yield client.fetch_certificate(orderr.body.certificate) 765 | defer.returnValue(certificate) 766 | 767 | 768 | JSON_CONTENT_TYPE = b'application/json' 769 | JOSE_CONTENT_TYPE = b'application/jose+json' 770 | JSON_ERROR_CONTENT_TYPE = b'application/problem+json' 771 | DER_CONTENT_TYPE = b'application/pkix-cert' 772 | PEM_CHAIN_TYPE = b'application/pem-certificate-chain' 773 | REPLAY_NONCE_HEADER = b'Replay-Nonce' 774 | 775 | 776 | class ServerError(Exception): 777 | """ 778 | :exc:`acme.messages.Error` isn't usable as an asynchronous exception, 779 | because it doesn't allow setting the ``__traceback__`` attribute like 780 | Twisted wants to do when cleaning Failures. This type exists to wrap such 781 | an error, as well as provide access to the original response. 782 | """ 783 | def __init__(self, message, response): 784 | Exception.__init__(self, message, response) 785 | self.message = message 786 | self.response = response 787 | 788 | def __repr__(self): 789 | return 'ServerError({!r})'.format(self.message) 790 | 791 | 792 | class AuthorizationFailed(Exception): 793 | """ 794 | An attempt was made to complete an authorization, but it failed. 795 | """ 796 | def __init__(self, authzr): 797 | self.status = authzr.body.status 798 | self.authzr = authzr 799 | self.errors = [ 800 | challb.error 801 | for challb in authzr.body.challenges 802 | if challb.error is not None] 803 | 804 | def __repr__(self): 805 | return ( 806 | 'AuthorizationFailed(<' 807 | '{0.status!r} ' 808 | '{0.authzr.body.identifier!r} ' 809 | '{0.errors!r}>)'.format(self)) 810 | 811 | def __str__(self): 812 | return repr(self) 813 | 814 | 815 | class NoSupportedChallenges(Exception): 816 | """ 817 | No supported challenges were found in an authorization. 818 | """ 819 | 820 | 821 | class JWSClient(object): 822 | """ 823 | HTTP client using JWS-signed messages for ACME. 824 | """ 825 | timeout = _DEFAULT_TIMEOUT 826 | 827 | def __init__(self, agent, key, alg, new_nonce_url, kid, 828 | user_agent=u'txacme/{}'.format(__version__).encode('ascii')): 829 | self._treq = HTTPClient(agent=agent) 830 | self._agent = agent 831 | self._current_request = None 832 | self._key = key 833 | self._alg = alg 834 | self._user_agent = user_agent 835 | 836 | self._nonces = set() 837 | self._new_nonce = new_nonce_url 838 | self._kid = kid 839 | 840 | @classmethod 841 | def from_directory(cls, agent, key, alg, directory): 842 | """ 843 | Prepare for ACME operations based on 'directory' url. 844 | 845 | :param str directory: The URL to the ACME v2 directory. 846 | 847 | :return: When operation is done. 848 | :rtype: Deferred[None] 849 | """ 850 | # Provide invalid new_nonce_url & kid, but don't expose it to the 851 | # caller. 852 | self = cls(agent, key, alg, None, None) 853 | 854 | def cb_extract_new_nonce(directory): 855 | try: 856 | self._new_nonce = directory.newNonce 857 | except AttributeError: 858 | raise errors.ClientError( 859 | 'Directory has no newNonce URL', directory) 860 | 861 | return directory 862 | return ( 863 | self.get(directory) 864 | .addCallback(json_content) 865 | .addCallback(messages.Directory.from_json) 866 | .addCallback(cb_extract_new_nonce) 867 | ) 868 | 869 | @classmethod 870 | def _check_response(cls, response, content_type=JSON_CONTENT_TYPE): 871 | """ 872 | Check response content and its type. 873 | 874 | .. note:: 875 | 876 | Unlike :mod:content_type`acme.client`, checking is strict. 877 | 878 | :param bytes content_type: Expected Content-Type response header. If 879 | the response Content-Type does not match, :exc:`ClientError` is 880 | raised. 881 | 882 | :raises .ServerError: If server response body carries HTTP Problem 883 | (draft-ietf-appsawg-http-problem-00). 884 | :raises ~acme.errors.ClientError: In case of other networking errors. 885 | """ 886 | def _got_failure(f): 887 | f.trap(ValueError) 888 | return None 889 | 890 | def _got_json(jobj): 891 | if 400 <= response.code < 600: 892 | if ( 893 | response_ct.lower().startswith(JSON_ERROR_CONTENT_TYPE) 894 | and jobj is not None 895 | ): 896 | raise ServerError( 897 | messages.Error.from_json(jobj), response) 898 | else: 899 | # response is not JSON object 900 | return _fail_and_consume( 901 | response, errors.ClientError('Response is not JSON.')) 902 | elif content_type not in response_ct.lower(): 903 | return _fail_and_consume(response, errors.ClientError( 904 | 'Unexpected response Content-Type: {0!r}. ' 905 | 'Expecting {1!r}.'.format( 906 | response_ct, content_type))) 907 | elif JSON_CONTENT_TYPE in content_type.lower() and jobj is None: 908 | return _fail_and_consume( 909 | response, errors.ClientError('Missing JSON body.')) 910 | return response 911 | 912 | response_ct = response.headers.getRawHeaders( 913 | b'Content-Type', [None])[0] 914 | action = LOG_JWS_CHECK_RESPONSE( 915 | expected_content_type=content_type, 916 | response_content_type=response_ct) 917 | with action.context(): 918 | # TODO: response.json() is called twice, once here, and 919 | # once in _get and _post clients 920 | return ( 921 | DeferredContext(response.json()) 922 | .addErrback(_got_failure) 923 | .addCallback(_got_json) 924 | .addActionFinish()) 925 | 926 | def _send_request(self, method, url, *args, **kwargs): 927 | """ 928 | Send HTTP request. 929 | 930 | :param str method: The HTTP method to use. 931 | :param str url: The URL to make the request to. 932 | 933 | :return: Deferred firing with the HTTP response. 934 | """ 935 | if self._current_request is not None: 936 | return defer.fail(RuntimeError('Overlapped HTTP request')) 937 | 938 | def cb_request_done(result): 939 | """ 940 | Called when we got a response from the request. 941 | """ 942 | self._current_request = None 943 | return result 944 | 945 | action = LOG_JWS_REQUEST(url=url) 946 | with action.context(): 947 | headers = kwargs.setdefault('headers', Headers()) 948 | headers.setRawHeaders(b'user-agent', [self._user_agent]) 949 | kwargs.setdefault('timeout', self.timeout) 950 | self._current_request = self._treq.request( 951 | method, url, *args, **kwargs) 952 | return ( 953 | DeferredContext(self._current_request) 954 | .addCallback(cb_request_done) 955 | .addCallback( 956 | tap(lambda r: action.add_success_fields( 957 | code=r.code, 958 | content_type=r.headers.getRawHeaders( 959 | b'content-type', [None])[0]))) 960 | .addActionFinish()) 961 | 962 | def stop(self): 963 | """ 964 | Stops the operation. 965 | 966 | This cancels pending operations and does cleanup. 967 | 968 | :return: A deferred which fires when the client is stopped. 969 | """ 970 | if self._current_request is not None: 971 | self._current_request.addErrback(lambda _: None) 972 | self._current_request.cancel() 973 | self._current_request = None 974 | 975 | agent_pool = getattr(self._agent, '_pool', None) 976 | if agent_pool: 977 | return agent_pool.closeCachedConnections() 978 | return defer.succeed(None) 979 | 980 | def head(self, url, *args, **kwargs): 981 | """ 982 | Send HEAD request without checking the response. 983 | 984 | Note that ``_check_response`` is not called, as there will be no 985 | response body to check. 986 | 987 | :param str url: The URL to make the request to. 988 | """ 989 | with LOG_JWS_HEAD().context(): 990 | return DeferredContext( 991 | self._send_request(u'HEAD', url, *args, **kwargs) 992 | ).addActionFinish() 993 | 994 | def get(self, url, content_type=JSON_CONTENT_TYPE, **kwargs): 995 | """ 996 | Send GET request and check response. 997 | 998 | :param str method: The HTTP method to use. 999 | :param str url: The URL to make the request to. 1000 | 1001 | :raises txacme.client.ServerError: If server response body carries HTTP 1002 | Problem (draft-ietf-appsawg-http-problem-00). 1003 | :raises acme.errors.ClientError: In case of other protocol errors. 1004 | 1005 | :return: Deferred firing with the checked HTTP response. 1006 | """ 1007 | with LOG_JWS_GET().context(): 1008 | return ( 1009 | DeferredContext(self._send_request(u'GET', url, **kwargs)) 1010 | .addCallback(self._check_response, content_type=content_type) 1011 | .addActionFinish()) 1012 | 1013 | def _add_nonce(self, response): 1014 | """ 1015 | Store a nonce from a response we received. 1016 | 1017 | :param twisted.web.iweb.IResponse response: The HTTP response. 1018 | 1019 | :return: The response, unmodified. 1020 | """ 1021 | nonce = response.headers.getRawHeaders( 1022 | REPLAY_NONCE_HEADER, [None])[0] 1023 | with LOG_JWS_ADD_NONCE(raw_nonce=nonce) as action: 1024 | if nonce is None: 1025 | return _fail_and_consume( 1026 | response, 1027 | errors.ClientError(str(errors.MissingNonce(response))), 1028 | ) 1029 | else: 1030 | try: 1031 | decoded_nonce = Header._fields['nonce'].decode( 1032 | nonce.decode('ascii') 1033 | ) 1034 | action.add_success_fields(nonce=decoded_nonce) 1035 | except DeserializationError as error: 1036 | return _fail_and_consume( 1037 | response, errors.BadNonce(nonce, error)) 1038 | self._nonces.add(decoded_nonce) 1039 | return response 1040 | 1041 | def _get_nonce(self, url): 1042 | """ 1043 | Get a nonce to use in a request, removing it from the nonces on hand. 1044 | """ 1045 | action = LOG_JWS_GET_NONCE() 1046 | if len(self._nonces) > 0: 1047 | with action: 1048 | nonce = self._nonces.pop() 1049 | action.add_success_fields(nonce=nonce) 1050 | return defer.succeed(nonce) 1051 | else: 1052 | with action.context(): 1053 | return ( 1054 | DeferredContext(self.head(self._new_nonce)) 1055 | .addCallback(self._add_nonce) 1056 | .addCallback(lambda _: self._nonces.pop()) 1057 | .addCallback(tap( 1058 | lambda nonce: action.add_success_fields(nonce=nonce))) 1059 | .addActionFinish()) 1060 | 1061 | def _post( 1062 | self, url, obj, content_type, 1063 | response_type=JSON_CONTENT_TYPE, kid=None, 1064 | **kwargs 1065 | ): 1066 | """ 1067 | POST an object and check the response. 1068 | 1069 | :param str url: The URL to request. 1070 | :param ~josepy.interfaces.JSONDeSerializable obj: The serializable 1071 | payload of the request. 1072 | :param bytes content_type: The expected content type of the response. 1073 | 1074 | :raises txacme.client.ServerError: If server response body carries HTTP 1075 | Problem (draft-ietf-appsawg-http-problem-00). 1076 | :raises acme.errors.ClientError: In case of other protocol errors. 1077 | """ 1078 | if kid is None: 1079 | kid = self._kid 1080 | 1081 | def cb_wrap_in_jws(nonce): 1082 | with LOG_JWS_SIGN(key_type=self._key.typ, alg=self._alg.name, 1083 | nonce=nonce): 1084 | if obj is None: 1085 | jobj = b'' 1086 | else: 1087 | jobj = obj.json_dumps().encode() 1088 | result = ( 1089 | JWS.sign( 1090 | payload=jobj, 1091 | key=self._key, 1092 | alg=self._alg, 1093 | nonce=nonce, 1094 | url=url, 1095 | kid=kid, 1096 | ) 1097 | .json_dumps() 1098 | .encode()) 1099 | return result 1100 | 1101 | with LOG_JWS_POST().context(): 1102 | headers = kwargs.setdefault('headers', Headers()) 1103 | headers.setRawHeaders(b'content-type', [JOSE_CONTENT_TYPE]) 1104 | return ( 1105 | DeferredContext(self._get_nonce(url)) 1106 | .addCallback(cb_wrap_in_jws) 1107 | .addCallback( 1108 | lambda data: self._send_request( 1109 | u'POST', url, data=data, **kwargs)) 1110 | .addCallback(self._add_nonce) 1111 | .addCallback(self._check_response, content_type=response_type) 1112 | .addActionFinish()) 1113 | 1114 | def post(self, url, obj, content_type=JOSE_CONTENT_TYPE, **kwargs): 1115 | """ 1116 | POST an object and check the response. Retry once if a badNonce error 1117 | is received. 1118 | 1119 | :param str url: The URL to request. 1120 | :param ~josepy.interfaces.JSONDeSerializable obj: The serializable 1121 | payload of the request. 1122 | :param bytes content_type: The expected content type of the response. 1123 | By default, JSON. 1124 | 1125 | :raises txacme.client.ServerError: If server response body carries HTTP 1126 | Problem (draft-ietf-appsawg-http-problem-00). 1127 | :raises acme.errors.ClientError: In case of other protocol errors. 1128 | """ 1129 | def retry_bad_nonce(f): 1130 | f.trap(ServerError) 1131 | # The current RFC draft defines the namespace as 1132 | # urn:ietf:params:acme:error:, but earlier drafts (and some 1133 | # current implementations) use urn:acme:error: instead. We 1134 | # don't really care about the namespace here, just the error code. 1135 | if f.value.message.typ.split(':')[-1] == 'badNonce': 1136 | # If one nonce is bad, others likely are too. Let's clear them 1137 | # and re-add the one we just got. 1138 | self._nonces.clear() 1139 | self._add_nonce(f.value.response) 1140 | return self._post(url, obj, content_type, **kwargs) 1141 | return f 1142 | return ( 1143 | self._post(url, obj, content_type, **kwargs) 1144 | .addErrback(retry_bad_nonce)) 1145 | 1146 | 1147 | def _fail_and_consume(response, error): 1148 | """ 1149 | Fail the deferred, but before the read all the pending data from the 1150 | response. 1151 | """ 1152 | def fail(_): 1153 | raise error 1154 | return response.text().addBoth(fail) 1155 | 1156 | 1157 | __all__ = [ 1158 | 'Client', 'JWSClient', 'ServerError', 'JSON_CONTENT_TYPE', 1159 | 'JSON_ERROR_CONTENT_TYPE', 'REPLAY_NONCE_HEADER', 'fqdn_identifier', 1160 | 'answer_challenge', 'get_certificate', 'NoSupportedChallenges', 1161 | 'AuthorizationFailed', 'DER_CONTENT_TYPE'] 1162 | -------------------------------------------------------------------------------- /src/txacme/errors.py: -------------------------------------------------------------------------------- 1 | """ 2 | Exception types for txacme. 3 | """ 4 | import attr 5 | 6 | 7 | @attr.s 8 | class NotInZone(ValueError): 9 | """ 10 | The given domain name is not in the configured zone. 11 | """ 12 | server_name = attr.ib() 13 | zone_name = attr.ib() 14 | 15 | def __str__(self): 16 | return repr(self) 17 | 18 | 19 | @attr.s 20 | class ZoneNotFound(ValueError): 21 | """ 22 | The configured zone was not found in the zones at the configured provider. 23 | """ 24 | zone_name = attr.ib() 25 | 26 | def __str__(self): 27 | return repr(self) 28 | 29 | 30 | __all__ = ['NotInZone', 'ZoneNotFound'] 31 | -------------------------------------------------------------------------------- /src/txacme/interfaces.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Interface definitions for txacme. 4 | """ 5 | from zope.interface import Attribute, Interface 6 | 7 | 8 | class IResponder(Interface): 9 | """ 10 | Configuration for a ACME challenge responder. 11 | 12 | The actual responder may exist somewhere else, this interface is merely for 13 | an object that knows how to configure it. 14 | """ 15 | challenge_type = Attribute( 16 | """ 17 | The type of challenge this responder is able to respond for. 18 | 19 | Must correspond to one of the types from `acme.challenges`; for 20 | example, ``u'http-01'``. 21 | """) 22 | 23 | def start_responding(server_name, challenge, response): 24 | """ 25 | Start responding for a particular challenge. 26 | 27 | :param str server_name: The server name for which the challenge is 28 | being completed. 29 | :param challenge: The `acme.challenges` challenge object; the exact 30 | type of this object depends on the challenge type. 31 | :param response: The `acme.challenges` response object; the exact type 32 | of this object depends on the challenge type. 33 | 34 | :rtype: ``Deferred`` 35 | :return: A deferred firing when the challenge is ready to be verified. 36 | """ 37 | 38 | def stop_responding(server_name, challenge, response): 39 | """ 40 | Stop responding for a particular challenge. 41 | 42 | May be a noop if a particular responder does not need or implement 43 | explicit cleanup; implementations should not rely on this method always 44 | being called. 45 | 46 | :param str server_name: The server name for which the challenge is 47 | being completed. 48 | :param challenge: The `acme.challenges` challenge object; the exact 49 | type of this object depends on the challenge type. 50 | :param response: The `acme.challenges` response object; the exact type 51 | of this object depends on the challenge type. 52 | """ 53 | 54 | 55 | class ICertificateStore(Interface): 56 | """ 57 | A store of certificate/keys/chains. 58 | """ 59 | def get(self, server_name): 60 | """ 61 | Retrieve the current PEM objects for the given server name. 62 | 63 | :param str server_name: The server name. 64 | 65 | :raises KeyError: if the given name does not exist in the store. 66 | 67 | :return: ``Deferred[List[:ref:`pem-objects`]]`` 68 | """ 69 | 70 | def store(self, server_name, pem_objects): 71 | """ 72 | Store PEM objects for the given server name. 73 | 74 | Implementations do not have to permit invoking this with a server name 75 | that was not already present in the store. 76 | 77 | :param str server_name: The server name to update. 78 | :param pem_objects: A list of :ref:`pem-objects`; must contain exactly 79 | one private key, a certificate corresponding to that private key, 80 | and zero or more chain certificates. 81 | 82 | :rtype: ``Deferred`` 83 | """ 84 | 85 | def as_dict(self): 86 | """ 87 | Get all certificates in the store. 88 | 89 | :rtype: ``Deferred[Dict[str, List[:ref:`pem-objects`]]]`` 90 | :return: A deferred firing with a dict mapping server names to 91 | :ref:`pem-objects`. 92 | """ 93 | 94 | 95 | __all__ = ['IResponder', 'ICertificateStore'] 96 | -------------------------------------------------------------------------------- /src/txacme/logging.py: -------------------------------------------------------------------------------- 1 | """ 2 | Eliot message and action definitions. 3 | """ 4 | from operator import methodcaller 5 | 6 | from eliot import ActionType, Field, fields 7 | from twisted.python.compat import unicode 8 | 9 | NONCE = Field( 10 | u'nonce', 11 | lambda nonce: nonce.encode('hex').decode('ascii'), 12 | u'A nonce value') 13 | 14 | LOG_JWS_SIGN = ActionType( 15 | u'txacme:jws:sign', 16 | fields(NONCE, key_type=unicode, alg=unicode, kid=unicode), 17 | fields(), 18 | u'Signing a message with JWS') 19 | 20 | LOG_JWS_HEAD = ActionType( 21 | u'txacme:jws:http:head', 22 | fields(), 23 | fields(), 24 | u'A JWSClient HEAD request') 25 | 26 | LOG_JWS_GET = ActionType( 27 | u'txacme:jws:http:get', 28 | fields(), 29 | fields(), 30 | u'A JWSClient GET request') 31 | 32 | LOG_JWS_POST = ActionType( 33 | u'txacme:jws:http:post', 34 | fields(), 35 | fields(), 36 | u'A JWSClient POST request') 37 | 38 | LOG_JWS_REQUEST = ActionType( 39 | u'txacme:jws:http:request', 40 | fields(url=unicode), 41 | fields(Field.for_types(u'content_type', 42 | [unicode, None], 43 | u'Content-Type header field'), 44 | code=int), 45 | u'A JWSClient request') 46 | 47 | LOG_JWS_CHECK_RESPONSE = ActionType( 48 | u'txacme:jws:http:check-response', 49 | fields(Field.for_types(u'response_content_type', 50 | [unicode, None], 51 | u'Content-Type header field'), 52 | expected_content_type=unicode), 53 | fields(), 54 | u'Checking a JWSClient response') 55 | 56 | LOG_JWS_GET_NONCE = ActionType( 57 | u'txacme:jws:nonce:get', 58 | fields(), 59 | fields(NONCE), 60 | u'Consuming a nonce') 61 | 62 | LOG_JWS_ADD_NONCE = ActionType( 63 | u'txacme:jws:nonce:add', 64 | fields(Field.for_types(u'raw_nonce', 65 | [bytes, None], 66 | u'Nonce header field')), 67 | fields(NONCE), 68 | u'Adding a nonce') 69 | 70 | LOG_HTTP_PARSE_LINKS = ActionType( 71 | u'txacme:http:parse-links', 72 | fields(raw_link=unicode), 73 | fields(parsed_links=dict), 74 | u'Parsing HTTP Links') 75 | 76 | DIRECTORY = Field(u'directory', methodcaller('to_json'), u'An ACME directory') 77 | 78 | URL = Field(u'url', methodcaller('asText'), u'A URL object') 79 | 80 | LOG_ACME_CONSUME_DIRECTORY = ActionType( 81 | u'txacme:acme:client:from-url', 82 | fields(URL, key_type=unicode, alg=unicode), 83 | fields(DIRECTORY), 84 | u'Creating an ACME client from a remote directory') 85 | 86 | LOG_ACME_REGISTER = ActionType( 87 | u'txacme:acme:client:registration:create', 88 | fields(Field(u'registration', 89 | methodcaller('to_json'), 90 | u'An ACME registration')), 91 | fields(Field(u'registration', 92 | methodcaller('to_json'), 93 | u'The resulting registration')), 94 | u'Registering with an ACME server') 95 | 96 | LOG_ACME_ANSWER_CHALLENGE = ActionType( 97 | u'txacme:acme:client:challenge:answer', 98 | fields(Field(u'challenge_body', 99 | methodcaller('to_json'), 100 | u'The challenge body'), 101 | Field(u'response', 102 | methodcaller('to_json'), 103 | u'The challenge response')), 104 | fields(Field(u'challenge_resource', 105 | methodcaller('to_json'), 106 | u'The updated challenge')), 107 | u'Answering an authorization challenge') 108 | -------------------------------------------------------------------------------- /src/txacme/messages.py: -------------------------------------------------------------------------------- 1 | """ 2 | ACME protocol messages. 3 | 4 | This module provides supplementary message implementations that are not already 5 | provided by the `acme` library. 6 | 7 | .. seealso:: `acme.messages` 8 | """ 9 | from acme.fields import Resource 10 | from josepy import Field, JSONObjectWithFields 11 | 12 | from txacme.util import decode_csr, encode_csr 13 | 14 | 15 | class CertificateRequest(JSONObjectWithFields): 16 | """ 17 | ACME new-cert request. 18 | 19 | Differs from the upstream version because it wraps a Cryptography CSR 20 | object instead of a PyOpenSSL one. 21 | 22 | .. seealso:: `acme.messages.CertificateRequest`, 23 | `cryptography.x509.CertificateSigningRequest` 24 | """ 25 | resource_type = 'new-cert' 26 | resource = Resource(resource_type) 27 | csr = Field('csr', decoder=decode_csr, encoder=encode_csr) 28 | 29 | 30 | __all__ = ['CertificateRequest'] 31 | -------------------------------------------------------------------------------- /src/txacme/newsfragments/.gitignore: -------------------------------------------------------------------------------- 1 | !.gitignore 2 | -------------------------------------------------------------------------------- /src/txacme/newsfragments/151-1.feature: -------------------------------------------------------------------------------- 1 | txacme.service.AcmeIssuingService.issue_cert now accepts a comma separated 2 | list of FQDN in order to generate SAN certificate. 3 | -------------------------------------------------------------------------------- /src/txacme/newsfragments/151-1.removal: -------------------------------------------------------------------------------- 1 | ACME v1 is no longer supported. 2 | -------------------------------------------------------------------------------- /src/txacme/newsfragments/151.feature: -------------------------------------------------------------------------------- 1 | Add support for ACME v2. 2 | -------------------------------------------------------------------------------- /src/txacme/newsfragments/151.removal: -------------------------------------------------------------------------------- 1 | The API for txacme.client.Client was completely change as ACME v2 has different 2 | primitives in comparison with ACME v1. 3 | -------------------------------------------------------------------------------- /src/txacme/newsfragments/86.bugfix: -------------------------------------------------------------------------------- 1 | INCOMPATIBLE CHANGE: txacme.service.AcmeIssuingService.stopFactory now 2 | closes the persisted HTTP client connections. 3 | This is done to bring the in a state similar to the one before calling 4 | startFactory. 5 | -------------------------------------------------------------------------------- /src/txacme/newsfragments/86.removal: -------------------------------------------------------------------------------- 1 | INCOMPATIBLE CHANGE: txacme.client.JWSClient is now initialized with an 2 | twisted.web.client.Agent instead of treq.client.HTTPClient. 3 | In this way the usage of Treq is internal to txacme. 4 | It was changed to make it easier to close the idle persistent connection. 5 | -------------------------------------------------------------------------------- /src/txacme/service.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from functools import partial 3 | 4 | import attr 5 | from cryptography import x509 6 | from cryptography.hazmat.backends import default_backend 7 | from cryptography.hazmat.primitives import serialization 8 | import pem 9 | from twisted.application.internet import TimerService 10 | from twisted.application.service import Service 11 | from twisted.internet import defer 12 | from twisted.logger import Logger 13 | 14 | from txacme.client import answer_challenge, get_certificate 15 | from txacme.util import clock_now, generate_private_key, tap 16 | 17 | 18 | log = Logger() 19 | 20 | 21 | def _default_panic(failure, server_name): 22 | log.failure( 23 | u'PANIC! Unable to renew certificate for: {server_name!r}', 24 | failure, server_name=server_name) 25 | 26 | 27 | @attr.s(cmp=False, hash=False) 28 | class AcmeIssuingService(Service): 29 | """ 30 | A service for keeping certificates up to date by using an ACME server. 31 | 32 | :type cert_store: `~txacme.interfaces.ICertificateStore` 33 | :param cert_store: The certificate store containing the certificates to 34 | manage. 35 | 36 | :type client: `txacme.client.Client` 37 | :param client: A client which is already set to be used for an 38 | environment. For example, ``Client.from_url(reactor=reactor, 39 | url=LETSENCRYPT_STAGING_DIRECTORY, key=acme_key, alg=RS256)``. 40 | When the service is stopped, it will automatically call the stop 41 | method on the client. 42 | 43 | :param clock: ``IReactorTime`` provider; usually the reactor, when not 44 | testing. 45 | 46 | :type responders: List[`~txacme.interfaces.IResponder`] 47 | :param responders: Challenge responders. Usually only one responder is 48 | needed; if more than one responder for the same type is provided, only 49 | the first will be used. 50 | :param str email: An (optional) email address to use during registration. 51 | :param ~datetime.timedelta check_interval: How often to check for expiring 52 | certificates. 53 | :param ~datetime.timedelta reissue_interval: If a certificate is expiring 54 | in less time than this interval, it will be reissued. 55 | :param ~datetime.timedelta panic_interval: If a certificate is expiring in 56 | less time than this interval, and reissuing fails, the panic callback 57 | will be invoked. 58 | 59 | :type panic: Callable[[Failure, `str`], Deferred] 60 | :param panic: A callable invoked with the failure and server name when 61 | reissuing fails for a certificate expiring in the ``panic_interval``. 62 | For example, you could generate a monitoring alert. The default 63 | callback logs a message at *CRITICAL* level. 64 | :param generate_key: A 0-arg callable used to generate a private key for a 65 | new cert. Normally you would not pass this unless you have specialized 66 | key generation requirements. 67 | """ 68 | cert_store = attr.ib() 69 | _client = attr.ib() 70 | _clock = attr.ib() 71 | _responders = attr.ib() 72 | _email = attr.ib(default=None) 73 | check_interval = attr.ib(default=timedelta(days=1)) 74 | reissue_interval = attr.ib(default=timedelta(days=30)) 75 | panic_interval = attr.ib(default=timedelta(days=15)) 76 | _panic = attr.ib(default=_default_panic) 77 | _generate_key = attr.ib(default=partial(generate_private_key, u'rsa')) 78 | 79 | _waiting = attr.ib(default=attr.Factory(list), init=False) 80 | _issuing = attr.ib(default=attr.Factory(dict), init=False) 81 | ready = False 82 | # Service used to repeatedly call the certificate check and renewal. 83 | _timer_service = None 84 | # Deferred of the current certificates check. 85 | # Added to help the automated testing. 86 | _ongoing_check = None 87 | 88 | def _now(self): 89 | """ 90 | Get the current time. 91 | """ 92 | return clock_now(self._clock) 93 | 94 | def _check_certs(self): 95 | """ 96 | Check all of the certs in the store, and reissue any that are expired 97 | or close to expiring. 98 | """ 99 | log.info('Starting scheduled check for expired certificates.') 100 | 101 | def check(certs): 102 | panicing = set() 103 | expiring = set() 104 | for server_names, objects in certs.items(): 105 | if len(objects) == 0: 106 | panicing.add(server_names) 107 | for o in filter( 108 | lambda o: isinstance(o, pem.Certificate), objects): 109 | cert = x509.load_pem_x509_certificate( 110 | o.as_bytes(), default_backend()) 111 | until_expiry = cert.not_valid_after - self._now() 112 | if until_expiry <= self.panic_interval: 113 | panicing.add(server_names) 114 | elif until_expiry <= self.reissue_interval: 115 | expiring.add(server_names) 116 | 117 | log.info( 118 | 'Found {panicing_count:d} overdue / expired and ' 119 | '{expiring_count:d} expiring certificates.', 120 | panicing_count=len(panicing), 121 | expiring_count=len(expiring)) 122 | 123 | d1 = ( 124 | defer.gatherResults( 125 | [self._issue_cert(server_names) 126 | .addErrback(self._panic, server_names) 127 | for server_names in panicing], 128 | consumeErrors=True) 129 | .addCallback(done_panicing)) 130 | d2 = defer.gatherResults( 131 | [self.issue_cert(server_names) 132 | .addErrback( 133 | lambda f: log.failure( 134 | u'Error issuing certificate for: {server_names!r}', 135 | f, server_names=server_names)) 136 | for server_names in expiring], 137 | consumeErrors=True) 138 | return defer.gatherResults([d1, d2], consumeErrors=True) 139 | 140 | def done_panicing(ignored): 141 | self.ready = True 142 | for d in list(self._waiting): 143 | d.callback(None) 144 | self._waiting = [] 145 | 146 | self._ongoing_check = ( 147 | self.cert_store.as_dict() 148 | .addCallback(check) 149 | .addErrback( 150 | lambda f: log.failure( 151 | u'Error in scheduled certificate check.', f))) 152 | return self._ongoing_check 153 | 154 | def issue_cert(self, server_names): 155 | """ 156 | Issue a new cert for a particular list of FQDNs. 157 | 158 | If an existing cert exists, it will be replaced with the new cert. If 159 | issuing is already in progress for the given name, a second issuing 160 | process will *not* be started. 161 | 162 | :param str server_names: The comma separated list of names to issue a 163 | cert for. 164 | 165 | :rtype: ``Deferred`` 166 | :return: A deferred that fires when issuing is complete. 167 | """ 168 | canonical_names = self._canonicalNames(server_names) 169 | 170 | def finish(result): 171 | _, waiting = self._issuing.pop(canonical_names) 172 | for d in waiting: 173 | d.callback(result) 174 | 175 | # d_issue is assigned below, in the conditional, since we may be 176 | # creating it or using the existing one. 177 | d = defer.Deferred(lambda _: d_issue.cancel()) 178 | if canonical_names in self._issuing: 179 | d_issue, waiting = self._issuing[canonical_names] 180 | waiting.append(d) 181 | else: 182 | d_issue = self._issue_cert(canonical_names) 183 | waiting = [d] 184 | self._issuing[canonical_names] = (d_issue, waiting) 185 | # Add the callback afterwards in case we're using a client 186 | # implementation that isn't actually async 187 | d_issue.addBoth(finish) 188 | return d 189 | 190 | @staticmethod 191 | def _canonicalNames(server_names): 192 | """ 193 | Return the canonical representation for `server_names`. 194 | """ 195 | names = [n.strip() for n in server_names.split(',')] 196 | return ','.join(names) 197 | 198 | def _issue_cert(self, server_names): 199 | """ 200 | Issue a new cert for the list of server_names. 201 | 202 | `server_names` is already canonized. 203 | """ 204 | names = [n.strip() for n in server_names.split(',')] 205 | 206 | log.info( 207 | 'Requesting a certificate for {server_names!r}.', 208 | server_names=server_names) 209 | key = self._generate_key() 210 | objects = [ 211 | pem.Key(key.private_bytes( 212 | encoding=serialization.Encoding.PEM, 213 | format=serialization.PrivateFormat.TraditionalOpenSSL, 214 | encryption_algorithm=serialization.NoEncryption()))] 215 | 216 | @defer.inlineCallbacks 217 | def answer_to_order(orderr): 218 | """ 219 | Answer the challenges associated with the order. 220 | """ 221 | for authorization in orderr.authorizations: 222 | yield answer_challenge( 223 | authorization, 224 | self._client, 225 | self._responders, 226 | clock=self._clock, 227 | ) 228 | certificate = yield get_certificate( 229 | orderr, self._client, clock=self._clock) 230 | defer.returnValue(certificate) 231 | 232 | def got_cert(certr): 233 | """ 234 | Called when we got a certificate. 235 | """ 236 | # The certificate is returned as chain. 237 | objects.extend(pem.parse(certr.body)) 238 | self.cert_store.store(','.join(names), objects) 239 | 240 | return ( 241 | self._client.submit_order(key, names) 242 | .addCallback(answer_to_order) 243 | .addCallback(got_cert) 244 | ) 245 | 246 | def when_certs_valid(self): 247 | """ 248 | Get a notification once the startup check has completed. 249 | 250 | When the service starts, an initial check is made immediately; the 251 | deferred returned by this function will only fire once reissue has been 252 | attempted for any certificates within the panic interval. 253 | 254 | .. note:: The reissue for any of these certificates may not have been 255 | successful; the panic callback will be invoked for any certificates 256 | in the panic interval that failed reissue. 257 | 258 | :rtype: ``Deferred`` 259 | :return: A deferred that fires once the initial check has resolved. 260 | """ 261 | if self.ready: 262 | return defer.succeed(None) 263 | d = defer.Deferred() 264 | self._waiting.append(d) 265 | return d 266 | 267 | def start(self): 268 | """ 269 | Like startService, but will return a deferred once the service was 270 | started and operational. 271 | """ 272 | Service.startService(self) 273 | 274 | def cb_start(result): 275 | """ 276 | Called when the client is ready for operation. 277 | """ 278 | self._timer_service = TimerService( 279 | self.check_interval.total_seconds(), self._check_certs) 280 | self._timer_service.clock = self._clock 281 | self._timer_service.startService() 282 | 283 | return self._client.start(email=self._email).addCallback(cb_start) 284 | 285 | def startService(self): 286 | """ 287 | Start operating the service. 288 | 289 | See `when_certs_valid` if you want to be notified when all the 290 | certificate from the storage were validated after startup. 291 | """ 292 | self.start().addErrback(self._panic, 'FAIL-TO-START') 293 | 294 | def stopService(self): 295 | Service.stopService(self) 296 | self.ready = False 297 | for d in list(self._waiting): 298 | d.cancel() 299 | self._waiting = [] 300 | 301 | def stop_timer(ignored): 302 | if not self._timer_service: 303 | return 304 | return self._timer_service.stopService() 305 | 306 | def cleanup(ignored): 307 | self._timer_service = None 308 | 309 | return ( 310 | self._client.stop() 311 | .addBoth(tap(stop_timer)) 312 | .addBoth(tap(cleanup)) 313 | ) 314 | 315 | 316 | __all__ = ['AcmeIssuingService'] 317 | -------------------------------------------------------------------------------- /src/txacme/store.py: -------------------------------------------------------------------------------- 1 | """ 2 | ``txacme.interfaces.ICertificateStore`` implementations. 3 | """ 4 | from operator import methodcaller 5 | 6 | import attr 7 | from pem import parse 8 | from twisted.internet.defer import maybeDeferred, succeed 9 | from zope.interface import implementer 10 | 11 | from txacme.interfaces import ICertificateStore 12 | 13 | 14 | @attr.s 15 | @implementer(ICertificateStore) 16 | class DirectoryStore(object): 17 | """ 18 | A certificate store that keeps certificates in a directory on disk. 19 | """ 20 | path = attr.ib(converter=methodcaller('asTextMode')) 21 | 22 | def _get(self, server_name): 23 | """ 24 | Synchronously retrieve an entry. 25 | """ 26 | p = self.path.child(server_name + u'.pem') 27 | if p.isfile(): 28 | return parse(p.getContent()) 29 | else: 30 | raise KeyError(server_name) 31 | 32 | def get(self, server_name): 33 | return maybeDeferred(self._get, server_name) 34 | 35 | def store(self, server_name, pem_objects): 36 | p = self.path.child(server_name + u'.pem') 37 | p.setContent(b''.join(o.as_bytes() for o in pem_objects)) 38 | return succeed(None) 39 | 40 | def as_dict(self): 41 | return succeed( 42 | {fn[:-4]: self._get(fn[:-4]) 43 | for fn in self.path.listdir() 44 | if fn.endswith(u'.pem')}) 45 | 46 | 47 | __all__ = ['DirectoryStore'] 48 | -------------------------------------------------------------------------------- /src/txacme/test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twisted/txacme/ac382ff1037cba8fb5ea6c90813eccfc69e53d36/src/txacme/test/__init__.py -------------------------------------------------------------------------------- /src/txacme/test/test_challenges.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for `txacme.challenges`. 3 | """ 4 | from operator import methodcaller 5 | 6 | from acme import challenges 7 | from josepy.b64 import b64encode 8 | from treq.testing import StubTreq 9 | from twisted._threads import createMemoryWorker 10 | from twisted.internet import defer 11 | from twisted.trial.unittest import TestCase 12 | 13 | from twisted.python.url import URL 14 | from twisted.web.resource import Resource 15 | from zope.interface.verify import verifyObject 16 | 17 | from txacme.challenges import HTTP01Responder 18 | from txacme.errors import NotInZone, ZoneNotFound 19 | from txacme.interfaces import IResponder 20 | from txacme.test.test_client import RSA_KEY_512, RSA_KEY_512_RAW 21 | 22 | 23 | # A random example token for the challenge tests that need one 24 | EXAMPLE_TOKEN = b'BWYcfxzmOha7-7LoxziqPZIUr99BCz3BfbN9kzSFnrU' 25 | 26 | 27 | class HTTPResponderTests(TestCase): 28 | """ 29 | `.HTTP01Responder` is a responder for http-01 challenges. 30 | """ 31 | 32 | def test_interface(self): 33 | """ 34 | The `.IResponder` interface is correctly implemented. 35 | """ 36 | responder = HTTP01Responder() 37 | verifyObject(IResponder, responder) 38 | self.assertEqual(u'http-01', responder.challenge_type) 39 | 40 | @defer.inlineCallbacks 41 | def test_stop_responding_already_stopped(self): 42 | """ 43 | Calling ``stop_responding`` when we are not responding for a server 44 | name does nothing. 45 | """ 46 | token = EXAMPLE_TOKEN 47 | challenge = challenges.HTTP01(token=token) 48 | response = challenge.response(RSA_KEY_512) 49 | responder = HTTP01Responder() 50 | 51 | yield responder.stop_responding( 52 | u'example.com', 53 | challenge, 54 | response) 55 | 56 | @defer.inlineCallbacks 57 | def test_start_responding(self): 58 | """ 59 | Calling ``start_responding`` makes an appropriate resource available. 60 | """ 61 | token = b'BWYcfxzmOha7-7LoxziqPZIUr99BCz3BfbN9kzSFnrU' 62 | challenge = challenges.HTTP01(token=token) 63 | response = challenge.response(RSA_KEY_512) 64 | 65 | responder = HTTP01Responder() 66 | 67 | challenge_resource = Resource() 68 | challenge_resource.putChild(b'acme-challenge', responder.resource) 69 | root = Resource() 70 | root.putChild(b'.well-known', challenge_resource) 71 | client = StubTreq(root) 72 | 73 | encoded_token = challenge.encode('token') 74 | challenge_url = URL(host=u'example.com', path=[ 75 | u'.well-known', u'acme-challenge', encoded_token]).asText() 76 | 77 | # We got page not found while the challenge is not yet active. 78 | result = yield client.get(challenge_url) 79 | self.assertEqual(404, result.code) 80 | 81 | # Once we enable the response. 82 | responder.start_responding(u'example.com', challenge, response) 83 | result = yield client.get(challenge_url) 84 | self.assertEqual(200, result.code) 85 | self.assertEqual( 86 | ['text/plain'], result.headers.getRawHeaders('content-type')) 87 | 88 | result = yield result.content() 89 | self.assertEqual(response.key_authorization.encode('utf-8'), result) 90 | 91 | # Starting twice before stopping doesn't break things 92 | responder.start_responding(u'example.com', challenge, response) 93 | 94 | result = yield client.get(challenge_url) 95 | self.assertEqual(200, result.code) 96 | 97 | yield responder.stop_responding(u'example.com', challenge, response) 98 | 99 | result = yield client.get(challenge_url) 100 | self.assertEqual(404, result.code) 101 | 102 | 103 | 104 | __all__ = [ 105 | 'HTTPResponderTests', 'TLSResponderTests', 'MergingProxyTests', 106 | ] 107 | -------------------------------------------------------------------------------- /src/txacme/test/test_client.py: -------------------------------------------------------------------------------- 1 | import json 2 | from contextlib import contextmanager 3 | from operator import attrgetter, methodcaller 4 | 5 | import attr 6 | 7 | from josepy.jwa import RS256, RS384 8 | from josepy.jwk import JWKRSA 9 | from josepy.jws import JWS 10 | from josepy.b64 import b64encode, b64decode 11 | 12 | from acme import challenges, errors, messages 13 | from cryptography.hazmat.backends import default_backend 14 | from cryptography.hazmat.primitives import serialization 15 | from cryptography.hazmat.primitives.asymmetric import rsa 16 | from treq.client import HTTPClient 17 | from treq.testing import RequestSequence as treq_RequestSequence 18 | from treq.testing import ( 19 | _SynchronousProducer, RequestTraversalAgent, StringStubbingResource) 20 | from twisted.internet import defer, reactor 21 | from twisted.internet.defer import Deferred, CancelledError, fail, succeed 22 | from twisted.internet.error import ConnectionClosed 23 | from twisted.internet.task import Clock 24 | from twisted.python.url import URL 25 | from twisted.test.proto_helpers import MemoryReactor 26 | from twisted.web import http, server 27 | from twisted.web.resource import Resource 28 | from twisted.web.http_headers import Headers 29 | from twisted.trial.unittest import TestCase 30 | from zope.interface import implementer 31 | 32 | from txacme.client import ( 33 | _default_client, _find_supported_challenge, _parse_header_links, 34 | answer_challenge, AuthorizationFailed, Client, DER_CONTENT_TYPE, 35 | fqdn_identifier, JSON_CONTENT_TYPE, JOSE_CONTENT_TYPE, 36 | JSON_ERROR_CONTENT_TYPE, JWSClient, NoSupportedChallenges, ServerError, 37 | get_certificate 38 | ) 39 | from txacme.interfaces import IResponder 40 | from txacme.messages import CertificateRequest 41 | from txacme.testing import NullResponder 42 | from txacme.util import ( 43 | csr_for_names, generate_private_key 44 | ) 45 | 46 | 47 | def failed_with(matcher): 48 | return failed(AfterPreprocessing(attrgetter('value'), matcher)) 49 | 50 | 51 | # from cryptography: 52 | 53 | RSA_KEY_512_RAW = rsa.RSAPrivateNumbers( 54 | p=int( 55 | "d57846898d5c0de249c08467586cb458fa9bc417cdf297f73cfc52281b787cd9", 16 56 | ), 57 | q=int( 58 | "d10f71229e87e010eb363db6a85fd07df72d985b73c42786191f2ce9134afb2d", 16 59 | ), 60 | d=int( 61 | "272869352cacf9c866c4e107acc95d4c608ca91460a93d28588d51cfccc07f449" 62 | "18bbe7660f9f16adc2b4ed36ca310ef3d63b79bd447456e3505736a45a6ed21", 16 63 | ), 64 | dmp1=int( 65 | "addff2ec7564c6b64bc670d250b6f24b0b8db6b2810099813b7e7658cecf5c39", 16 66 | ), 67 | dmq1=int( 68 | "463ae9c6b77aedcac1397781e50e4afc060d4b216dc2778494ebe42a6850c81", 16 69 | ), 70 | iqmp=int( 71 | "54deef8548f65cad1d411527a32dcb8e712d3e128e4e0ff118663fae82a758f4", 16 72 | ), 73 | public_numbers=rsa.RSAPublicNumbers( 74 | e=65537, 75 | n=int( 76 | "ae5411f963c50e3267fafcf76381c8b1e5f7b741fdb2a544bcf48bd607b10c991" 77 | "90caeb8011dc22cf83d921da55ec32bd05cac3ee02ca5e1dbef93952850b525", 78 | 16 79 | ), 80 | ) 81 | ).private_key(default_backend()) 82 | 83 | RSA_KEY_512 = JWKRSA(key=RSA_KEY_512_RAW) 84 | 85 | 86 | class RequestSequence(treq_RequestSequence): 87 | @contextmanager 88 | def consume(self, sync_failure_reporter): 89 | yield 90 | if not self.consumed(): 91 | sync_failure_reporter("\n".join( 92 | ["Not all expected requests were made. Still expecting:"] + 93 | ["- {0!r})".format(e) for e, _ in self._sequence])) 94 | 95 | def __call__(self, method, url, params, headers, data): 96 | """ 97 | :return: the next response in the sequence, provided that the 98 | parameters match the next in the sequence. 99 | """ 100 | req = (method, url, params, headers, data) 101 | if len(self._sequence) == 0: 102 | self._async_reporter( 103 | None, Never(), 104 | "No more requests expected, but request {0!r} made.".format( 105 | req)) 106 | return (500, {}, "StubbingError") 107 | matcher, response = self._sequence[0] 108 | self._async_reporter(req, matcher) 109 | self._sequence = self._sequence[1:] 110 | return response 111 | 112 | 113 | def on_json(matcher): 114 | def _loads(s): 115 | assert isinstance(s, bytes) 116 | s = s.decode('utf-8') 117 | return json.loads(s) 118 | return AfterPreprocessing(_loads, matcher) 119 | 120 | 121 | def on_jws(matcher, nonce=None): 122 | nonce_matcher = Always() 123 | if nonce is not None: 124 | def extract_nonce(j): 125 | protected = json.loads(j.signatures[0].protected) 126 | return b64decode(protected[u'nonce']) 127 | nonce_matcher = AfterPreprocessing(extract_nonce, Equals(nonce)) 128 | return on_json( 129 | AfterPreprocessing( 130 | JWS.from_json, 131 | MatchesAll( 132 | MatchesPredicate( 133 | methodcaller('verify'), '%r does not verify'), 134 | AfterPreprocessing( 135 | attrgetter('payload'), 136 | on_json(matcher)), 137 | nonce_matcher))) 138 | 139 | 140 | @attr.s 141 | class TestResponse(object): 142 | """ 143 | Test response implementation for various bad response cases. 144 | """ 145 | code = attr.ib(default=http.OK) 146 | content_type = attr.ib(default=JSON_CONTENT_TYPE) 147 | nonce = attr.ib(default=None) 148 | json = attr.ib(default=lambda: succeed({})) 149 | links = attr.ib(default=None) 150 | 151 | @property 152 | def headers(self): 153 | h = Headers({b'content-type': [self.content_type]}) 154 | if self.nonce is not None: 155 | h.setRawHeaders(b'replay-nonce', [self.nonce]) 156 | if self.links is not None: 157 | h.setRawHeaders(b'link', self.links) 158 | return h 159 | 160 | 161 | @implementer(IResponder) 162 | @attr.s 163 | class RecordingResponder(object): 164 | challenges = attr.ib() 165 | challenge_type = attr.ib() 166 | 167 | def start_responding(self, server_name, challenge, response): 168 | self.challenges.add(challenge) 169 | 170 | def stop_responding(self, server_name, challenge, response): 171 | self.challenges.discard(challenge) 172 | 173 | 174 | class ClientTests(TestCase): 175 | """ 176 | :class:`.Client` provides a client interface for the ACME API. 177 | """ 178 | 179 | @defer.inlineCallbacks 180 | def test_directory_url_type(self): 181 | """ 182 | `~txacme.client.Client.from_url` expects a ``twisted.python.url.URL`` 183 | instance for the ``url`` argument. 184 | """ 185 | with self.assertRaises(TypeError): 186 | yield Client.from_url( 187 | reactor, '/wrong/kind/of/directory', key=RSA_KEY_512) 188 | 189 | def test_fqdn_identifier(self): 190 | """ 191 | `~txacme.client.fqdn_identifier` constructs an 192 | `~acme.messages.Identifier` of the right type. 193 | """ 194 | name = u'example.com' 195 | result = fqdn_identifier(name) 196 | self.assertEqual(messages.IDENTIFIER_FQDN, result.typ) 197 | self.assertEqual(name, result.value) 198 | 199 | def test_challenge_unexpected_uri(self): 200 | """ 201 | ``_check_challenge`` raises `~acme.errors.UnexpectedUpdate` if the 202 | challenge does not have the expected URI. 203 | """ 204 | # Crazy dance that was used in previous test. 205 | url1 = URL.fromText(u'https://example.org/').asURI().asText() 206 | url2 = URL.fromText(u'https://example.com/').asURI().asText() 207 | 208 | with self.assertRaises(errors.UnexpectedUpdate): 209 | Client._check_challenge( 210 | challenge=messages.ChallengeResource( 211 | body=messages.ChallengeBody(chall=None, uri=url1)), 212 | challenge_body=messages.ChallengeBody(chall=None, uri=url2), 213 | ) 214 | 215 | 216 | class JWSClientTests(TestCase): 217 | """ 218 | :class:`.JWSClient` implements JWS-signed requests over HTTP. 219 | """ 220 | @defer.inlineCallbacks 221 | def test_check_invalid_error(self): 222 | """ 223 | If an error response is received but cannot be parsed, 224 | :exc:`~acme.errors.ServerError` is raised. 225 | """ 226 | response = TestResponse( 227 | code=http.FORBIDDEN, 228 | content_type=JSON_ERROR_CONTENT_TYPE) 229 | 230 | with self.assertRaises(ServerError): 231 | yield JWSClient._check_response(response) 232 | 233 | @defer.inlineCallbacks 234 | def test_check_valid_error(self): 235 | """ 236 | If an error response is received but cannot be parsed, 237 | :exc:`~acme.errors.ClientError` is raised. 238 | """ 239 | response = TestResponse( 240 | code=http.FORBIDDEN, 241 | content_type=JSON_ERROR_CONTENT_TYPE, 242 | json=lambda: succeed({ 243 | u'type': u'unauthorized', 244 | u'detail': u'blah blah blah'})) 245 | 246 | with self.assertRaises(ServerError): 247 | yield JWSClient._check_response(response) 248 | 249 | 250 | class LinkParsingTests(TestCase): 251 | """ 252 | ``_parse_header_links`` parses the links from a response with Link: header 253 | fields. This implementation is ... actually not very good, which is why 254 | there aren't many tests. 255 | 256 | .. seealso: RFC 5988 257 | """ 258 | def test_rfc_example1(self): 259 | """ 260 | The first example from the RFC. 261 | """ 262 | response = TestResponse(links=[ 263 | b'; ' 264 | b'rel="previous"; ' 265 | b'title="previous chapter"']) 266 | result = _parse_header_links(response) 267 | self.assertEqual({ 268 | u'previous': 269 | {u'rel': u'previous', 270 | u'title': u'previous chapter', 271 | u'url': u'http://example.com/TheBook/chapter2'} 272 | }, 273 | result) 274 | 275 | 276 | __all__ = ['ClientTests', 'ExtraCoverageTests', 'LinkParsingTests'] 277 | -------------------------------------------------------------------------------- /src/txacme/test/test_store.py: -------------------------------------------------------------------------------- 1 | from operator import methodcaller 2 | import os 3 | import tempfile 4 | import shutil 5 | 6 | import pem 7 | from twisted.internet import defer 8 | from twisted.python.compat import unicode 9 | from twisted.python.filepath import FilePath 10 | from twisted.trial.unittest import TestCase 11 | 12 | from txacme.store import DirectoryStore 13 | from txacme.testing import MemoryStore 14 | 15 | 16 | EXAMPLE_PEM_OBJECTS = [ 17 | pem.RSAPrivateKey( 18 | b'-----BEGIN RSA PRIVATE KEY-----\n' 19 | b'iq63EP+H3w==\n' 20 | b'-----END RSA PRIVATE KEY-----\n'), 21 | pem.Certificate( 22 | b'-----BEGIN CERTIFICATE-----\n' 23 | b'yns=\n' 24 | b'-----END CERTIFICATE-----\n'), 25 | pem.Certificate( 26 | b'-----BEGIN CERTIFICATE-----\n' 27 | b'pNaiqhAT\n' 28 | b'-----END CERTIFICATE-----\n'), 29 | ] 30 | 31 | EXAMPLE_PEM_OBJECTS2 = [ 32 | pem.RSAPrivateKey( 33 | b'-----BEGIN RSA PRIVATE KEY-----\n' 34 | b'fQ==\n' 35 | b'-----END RSA PRIVATE KEY-----\n'), 36 | pem.Certificate( 37 | b'-----BEGIN CERTIFICATE-----\n' 38 | b'xUg=\n' 39 | b'-----END CERTIFICATE-----\n'), 40 | ] 41 | 42 | 43 | class _StoreTestsMixin(object): 44 | """ 45 | Tests for `txacme.interfaces.ICertificateStore` implementations. 46 | """ 47 | 48 | @defer.inlineCallbacks 49 | def test_insert(self): 50 | """ 51 | Inserting an entry causes the same entry to be returned by ``get`` and 52 | ``as_dict``. 53 | """ 54 | server_name = 'example.com' 55 | pem_objects = EXAMPLE_PEM_OBJECTS 56 | cert_store = self.getCertStore() 57 | 58 | result = yield cert_store.store(server_name, pem_objects) 59 | self.assertIsNone(result) 60 | 61 | result = yield cert_store.get(server_name) 62 | self.assertEqual(pem_objects, result) 63 | 64 | result = yield cert_store.as_dict() 65 | self.assertEqual({'example.com': pem_objects}, result) 66 | 67 | @defer.inlineCallbacks 68 | def test_insert_twice(self): 69 | """ 70 | Inserting an entry a second time overwrites the first entry. 71 | """ 72 | server_name = u'example.com' 73 | pem_objects = EXAMPLE_PEM_OBJECTS 74 | pem_objects2 = EXAMPLE_PEM_OBJECTS2 75 | cert_store = self.getCertStore() 76 | 77 | result = yield cert_store.store(server_name, pem_objects) 78 | self.assertIsNone(result) 79 | 80 | result = yield cert_store.store(server_name, pem_objects2) 81 | self.assertIsNone(result) 82 | 83 | result = yield cert_store.get(server_name) 84 | self.assertEqual(result, pem_objects2) 85 | 86 | result = yield cert_store.as_dict() 87 | self.assertEqual({'example.com': pem_objects2}, result) 88 | 89 | @defer.inlineCallbacks 90 | def test_get_missing(self): 91 | """ 92 | Getting a non-existent entry results in `KeyError`. 93 | """ 94 | cert_store = self.getCertStore() 95 | 96 | with self.assertRaises(KeyError): 97 | yield cert_store.get(u'example.com') 98 | 99 | @defer.inlineCallbacks 100 | def test_unicode_keys(self): 101 | """ 102 | The keys of the dict returned by ``as_dict`` are ``unicode``. 103 | """ 104 | cert_store = self.getCertStore() 105 | 106 | result = yield cert_store.store( 107 | u'example.com', EXAMPLE_PEM_OBJECTS) 108 | self.assertIsNone(result) 109 | 110 | result = yield cert_store.as_dict() 111 | self.assertEqual(['example.com'], list(result.keys())) 112 | 113 | 114 | class DirectoryStoreTests(_StoreTestsMixin, TestCase): 115 | """ 116 | Tests for `txacme.store.DirectoryStore`. 117 | """ 118 | 119 | def getCertStore(self): 120 | """ 121 | Return the certificate store for these tests. 122 | """ 123 | # FIXME 124 | # rever to trial mktemp. 125 | tmpdir = tempfile.mkdtemp() 126 | subdir = os.path.join(tmpdir, self._testMethodName) 127 | os.mkdir(subdir) 128 | self.addCleanup(shutil.rmtree, tmpdir) 129 | 130 | return DirectoryStore(FilePath(tmpdir)) 131 | 132 | def test_filepath_mode(self): 133 | """ 134 | The given ``FilePath`` is always converted to text mode. 135 | """ 136 | store = DirectoryStore(FilePath(b'bytesbytesbytes')) 137 | self.assertIsInstance(store.path.path, unicode) 138 | 139 | 140 | class MemoryStoreTests(_StoreTestsMixin, TestCase): 141 | """ 142 | Tests for `txacme.testing.MemoryStore`. 143 | """ 144 | 145 | def getCertStore(self): 146 | """ 147 | Return the certificate store for these tests. 148 | """ 149 | return MemoryStore() 150 | 151 | 152 | __all__ = ['DirectoryStoreTests', 'MemoryStoreTests'] 153 | -------------------------------------------------------------------------------- /src/txacme/test/test_util.py: -------------------------------------------------------------------------------- 1 | from codecs import decode 2 | 3 | import attr 4 | from OpenSSL import crypto 5 | from acme import challenges 6 | from josepy.b64 import b64encode 7 | from josepy.errors import DeserializationError 8 | from cryptography import x509 9 | from cryptography.hazmat.primitives import hashes 10 | from cryptography.hazmat.primitives.asymmetric import rsa 11 | from cryptography.x509.oid import NameOID 12 | from service_identity.pyopenssl import verify_hostname 13 | from twisted.trial.unittest import TestCase 14 | 15 | from txacme.test.test_client import RSA_KEY_512, RSA_KEY_512_RAW 16 | from txacme.util import ( 17 | const, csr_for_names, decode_csr, encode_csr, 18 | generate_private_key) 19 | 20 | 21 | class GeneratePrivateKeyTests(TestCase): 22 | """ 23 | `.generate_private_key` generates private keys of various types using 24 | sensible parameters. 25 | """ 26 | 27 | def test_unknown_key_type(self): 28 | """ 29 | Passing an unknown key type results in :exc:`.ValueError`. 30 | """ 31 | with self.assertRaises(ValueError): 32 | generate_private_key(u'not-a-real-key-type') 33 | 34 | def test_rsa_key(self): 35 | """ 36 | Passing ``u'rsa'`` results in an RSA private key. 37 | """ 38 | key1 = generate_private_key(u'rsa') 39 | self.assertIsInstance(key1,rsa.RSAPrivateKey) 40 | key2 = generate_private_key(u'rsa') 41 | self.assertIsInstance(key2, rsa.RSAPrivateKey) 42 | self.assertNotEqual( 43 | key1.public_key().public_numbers(), 44 | key2.public_key().public_numbers() 45 | ) 46 | -------------------------------------------------------------------------------- /src/txacme/testing.py: -------------------------------------------------------------------------------- 1 | """ 2 | Utilities for testing with txacme. 3 | """ 4 | from collections import OrderedDict 5 | from datetime import timedelta 6 | from uuid import uuid4 7 | 8 | import attr 9 | from acme import challenges, messages 10 | from cryptography import x509 11 | from cryptography.hazmat.backends import default_backend 12 | from cryptography.hazmat.primitives import hashes, serialization 13 | from cryptography.x509.oid import ExtensionOID, NameOID 14 | from twisted.internet import reactor 15 | from twisted.internet.defer import Deferred, fail, succeed 16 | from twisted.python.compat import unicode 17 | from twisted.trial.unittest import TestCase 18 | from zope.interface import implementer 19 | 20 | from txacme.interfaces import ICertificateStore, IResponder 21 | from txacme.util import clock_now, generate_private_key 22 | 23 | 24 | class TXACMETestCase(TestCase): 25 | """ 26 | Common code for all tests for the txacme project. 27 | """ 28 | 29 | def tearDown(self): 30 | super(TXACMETestCase, self).tearDown() 31 | 32 | # Make sure the main reactor is clean after each test. 33 | junk = [] 34 | for delayed_call in reactor.getDelayedCalls(): 35 | junk.append(delayed_call.func) 36 | delayed_call.cancel() 37 | if junk: 38 | raise AssertionError( 39 | 'Reactor is not clean. DelayedCalls: %s' % (junk,)) 40 | 41 | 42 | 43 | @implementer(IResponder) 44 | @attr.s 45 | class NullResponder(object): 46 | """ 47 | A responder that does absolutely nothing. 48 | """ 49 | challenge_type = attr.ib() 50 | 51 | def start_responding(self, server_name, challenge, response): 52 | pass 53 | 54 | def stop_responding(self, server_name, challenge, response): 55 | pass 56 | 57 | 58 | @implementer(ICertificateStore) 59 | class MemoryStore(object): 60 | """ 61 | A certificate store that keeps certificates in memory only. 62 | """ 63 | def __init__(self, certs=None): 64 | if certs is None: 65 | self._store = {} 66 | else: 67 | self._store = dict(certs) 68 | 69 | def get(self, server_name): 70 | try: 71 | return succeed(self._store[server_name]) 72 | except KeyError: 73 | return fail() 74 | 75 | def store(self, server_name, pem_objects): 76 | self._store[server_name] = pem_objects 77 | return succeed(None) 78 | 79 | def as_dict(self): 80 | return succeed(self._store) 81 | 82 | 83 | __all__ = ['MemoryStore', 'NullResponder'] 84 | -------------------------------------------------------------------------------- /src/txacme/urls.py: -------------------------------------------------------------------------------- 1 | from twisted.python.url import URL 2 | 3 | 4 | LETSENCRYPT_DIRECTORY = URL.fromText( 5 | u'https://acme-v02.api.letsencrypt.org/directory') 6 | 7 | 8 | LETSENCRYPT_STAGING_DIRECTORY = URL.fromText( 9 | u'https://acme-staging-v02.api.letsencrypt.org/directory') 10 | 11 | 12 | __all__ = ['LETSENCRYPT_DIRECTORY', 'LETSENCRYPT_STAGING_DIRECTORY'] 13 | -------------------------------------------------------------------------------- /src/txacme/util.py: -------------------------------------------------------------------------------- 1 | """ 2 | Utility functions that may prove useful when writing an ACME client. 3 | """ 4 | import uuid 5 | from datetime import datetime, timedelta 6 | from functools import wraps 7 | 8 | from josepy.errors import DeserializationError 9 | from josepy.json_util import encode_b64jose, decode_b64jose 10 | 11 | from cryptography import x509 12 | from cryptography.hazmat.backends import default_backend 13 | from cryptography.hazmat.primitives import hashes, serialization 14 | from cryptography.hazmat.primitives.asymmetric import rsa 15 | from cryptography.x509.oid import NameOID 16 | from twisted.internet.defer import maybeDeferred 17 | from twisted.python.url import URL 18 | 19 | 20 | def generate_private_key(key_type): 21 | """ 22 | Generate a random private key using sensible parameters. 23 | 24 | :param str key_type: The type of key to generate. One of: ``rsa``. 25 | """ 26 | if key_type == u'rsa': 27 | return rsa.generate_private_key( 28 | public_exponent=65537, key_size=2048, backend=default_backend()) 29 | raise ValueError(key_type) 30 | 31 | 32 | def load_or_create_client_key(pem_path): 33 | """ 34 | Load the client key from a directory, creating it if it does not exist. 35 | 36 | .. note:: The client key that will be created will be a 2048-bit RSA key. 37 | 38 | :type pem_path: ``twisted.python.filepath.FilePath`` 39 | :param pem_path: The certificate directory 40 | to use, as with the endpoint. 41 | """ 42 | acme_key_file = pem_path.asTextMode().child(u'client.key') 43 | if acme_key_file.exists(): 44 | key = serialization.load_pem_private_key( 45 | acme_key_file.getContent(), 46 | password=None, 47 | backend=default_backend()) 48 | else: 49 | key = generate_private_key(u'rsa') 50 | acme_key_file.setContent( 51 | key.private_bytes( 52 | encoding=serialization.Encoding.PEM, 53 | format=serialization.PrivateFormat.TraditionalOpenSSL, 54 | encryption_algorithm=serialization.NoEncryption())) 55 | return JWKRSA(key=key) 56 | 57 | 58 | def tap(f): 59 | """ 60 | "Tap" a Deferred callback chain with a function whose return value is 61 | ignored. 62 | """ 63 | @wraps(f) 64 | def _cb(res, *a, **kw): 65 | d = maybeDeferred(f, res, *a, **kw) 66 | d.addCallback(lambda ignored: res) 67 | return d 68 | return _cb 69 | 70 | 71 | def encode_csr(csr): 72 | """ 73 | Encode CSR as JOSE Base-64 DER. 74 | 75 | :param cryptography.x509.CertificateSigningRequest csr: The CSR. 76 | 77 | :rtype: str 78 | """ 79 | return encode_b64jose(csr.public_bytes(serialization.Encoding.DER)) 80 | 81 | 82 | def decode_csr(b64der): 83 | """ 84 | Decode JOSE Base-64 DER-encoded CSR. 85 | 86 | :param str b64der: The encoded CSR. 87 | 88 | :rtype: `cryptography.x509.CertificateSigningRequest` 89 | :return: The decoded CSR. 90 | """ 91 | try: 92 | return x509.load_der_x509_csr( 93 | decode_b64jose(b64der), default_backend()) 94 | except ValueError as error: 95 | raise DeserializationError(error) 96 | 97 | 98 | def csr_for_names(names, key): 99 | """ 100 | Generate a certificate signing request for the given names and private key. 101 | 102 | .. seealso:: `acme.client.Client.request_issuance` 103 | 104 | .. seealso:: `generate_private_key` 105 | 106 | :param ``List[str]``: One or more names (subjectAltName) for which to 107 | request a certificate. 108 | :param key: A Cryptography private key object. 109 | 110 | :rtype: `cryptography.x509.CertificateSigningRequest` 111 | :return: The certificate request message. 112 | """ 113 | if len(names) == 0: 114 | raise ValueError('Must have at least one name') 115 | if len(names[0]) > 64: 116 | common_name = u'san.too.long.invalid' 117 | else: 118 | common_name = names[0] 119 | return ( 120 | x509.CertificateSigningRequestBuilder() 121 | .subject_name(x509.Name([ 122 | x509.NameAttribute(NameOID.COMMON_NAME, common_name)])) 123 | .add_extension( 124 | x509.SubjectAlternativeName(list(map(x509.DNSName, names))), 125 | critical=False) 126 | .sign(key, hashes.SHA256(), default_backend())) 127 | 128 | 129 | def clock_now(clock): 130 | """ 131 | Get a datetime representing the current time. 132 | 133 | :param clock: An ``IReactorTime`` provider. 134 | 135 | :rtype: `~datetime.datetime` 136 | :return: A datetime representing the current time. 137 | """ 138 | return datetime.utcfromtimestamp(clock.seconds()) 139 | 140 | 141 | def check_directory_url_type(url): 142 | """ 143 | Check that ``url`` is a ``twisted.python.url.URL`` instance, raising 144 | `TypeError` if it isn't. 145 | """ 146 | if not isinstance(url, URL): 147 | raise TypeError( 148 | 'ACME directory URL should be a twisted.python.url.URL, ' 149 | 'got {!r} instead'.format(url)) 150 | 151 | 152 | def const(x): 153 | """ 154 | Return a constant function. 155 | """ 156 | return lambda: x 157 | 158 | 159 | __all__ = [ 160 | 'generate_private_key', 'generate_tls_sni_01_cert', 161 | 'encode_csr', 'decode_csr', 'csr_for_names', 'clock_now', 162 | 'check_directory_url_type', 'const', 'tap'] 163 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = coverage-clean,{py36,py38,pypy3}-{twlatest,twtrunk,twlowest}-{aclatest,acmaster}-alldeps,flake8,docs,coverage-report 3 | 4 | [testenv] 5 | setenv = 6 | PYTHONWARNINGS = default::DeprecationWarning 7 | HYPOTHESIS_PROFILE = coverage 8 | whitelist_externals = 9 | mkdir 10 | deps = 11 | .[test] 12 | alldeps: .[libcloud] 13 | acmaster: https://github.com/certbot/certbot/archive/master.zip#egg=acme&subdirectory=acme 14 | twlatest: Twisted[tls] 15 | twtrunk: https://github.com/twisted/twisted/archive/trunk.zip#egg=Twisted[tls] 16 | twlowest: Twisted[tls]==16.2.0 17 | coverage 18 | commands = 19 | pip list 20 | mkdir -p {envtmpdir} 21 | coverage run --parallel-mode \ 22 | {envdir}/bin/trial --temp-directory={envtmpdir}/_trial_temp {posargs:txacme integration} 23 | 24 | [testenv:flake8] 25 | basepython = python3.8 26 | deps = 27 | flake8 28 | pep8-naming 29 | commands = flake8 src setup.py docs/client_example.py docs/service_example.py 30 | 31 | [testenv:coverage-clean] 32 | deps = coverage 33 | skip_install = true 34 | commands = coverage erase 35 | 36 | [testenv:coverage-report] 37 | deps = 38 | coverage 39 | diff_cover 40 | skip_install = true 41 | commands = 42 | coverage combine 43 | coverage report 44 | coverage xml -o {envtmpdir}/coverage.xml 45 | diff-cover {envtmpdir}/coverage.xml 46 | 47 | [testenv:docs] 48 | whitelist_externals = 49 | rm 50 | test 51 | cat 52 | changedir = docs 53 | deps = 54 | -rrequirements-doc.txt 55 | commands = 56 | rm -rf {toxinidir}/docs/api/ 57 | rm -f {envtmpdir}/errors 58 | sphinx-build -W -w {envtmpdir}/errors --keep-going \ 59 | -n -b html -d {envtmpdir}/doctrees . {envtmpdir}/html 60 | cat {envtmpdir}/errors 61 | test ! -s {envtmpdir}/errors 62 | --------------------------------------------------------------------------------