././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1631387942.7756665 acme-tiny-5.0.1/0000775000175000017500000000000000000000000013526 5ustar00user1user100000000000000././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1631387942.7716665 acme-tiny-5.0.1/.github/0000775000175000017500000000000000000000000015066 5ustar00user1user100000000000000././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1631387942.7756665 acme-tiny-5.0.1/.github/workflows/0000775000175000017500000000000000000000000017123 5ustar00user1user100000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1629580032.0 acme-tiny-5.0.1/.github/workflows/full-tests-with-coverage.yml0000664000175000017500000000474200000000000024521 0ustar00user1user100000000000000name: Tests on: [push] jobs: test: name: Run tests strategy: matrix: include: - test-name: ubuntu-20.04-python-2.7 os: ubuntu-20.04 python-version: 2.7 - test-name: ubuntu-18.04-python-3.4 os: ubuntu-18.04 python-version: 3.4 - test-name: ubuntu-20.04-python-3.5 os: ubuntu-20.04 python-version: 3.5 - test-name: ubuntu-20.04-python-3.6 os: ubuntu-20.04 python-version: 3.6 - test-name: ubuntu-20.04-python-3.7 os: ubuntu-20.04 python-version: 3.7 - test-name: ubuntu-20.04-python-3.8 os: ubuntu-20.04 python-version: 3.8 runs-on: ${{ matrix.os }} steps: - name: Checkout repo uses: actions/checkout@v2 - name: Setup go uses: actions/setup-go@v1 with: go-version: 1.13 - name: Setup pebble run: | export PATH=$PATH:$(go env GOPATH)/bin go get -u github.com/letsencrypt/pebble/... cd $GOPATH/src/github.com/letsencrypt/pebble && go install ./... pebble -h || true - name: Setup python uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip pip install virtualenv pip install -U -r tests/requirements.txt - name: Run tests with coverage run: | export PEBBLE_BIN=$(go env GOPATH)/bin/pebble coverage run --source . --omit ./setup.py -m unittest tests - name: Print coverage report run: coverage report -m - name: Upload coverage to coveralls.io env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_TOKEN }} COVERALLS_SERVICE_NAME: github-actions COVERALLS_FLAG_NAME: ${{ matrix.test-name }} COVERALLS_PARALLEL: true run: | python -m pip install --upgrade coveralls coveralls coveralls: name: Indicate completion to coveralls.io needs: test runs-on: ubuntu-latest container: python:3-slim steps: - name: Finished env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | python -m pip install --upgrade coveralls coveralls --service=github --finish ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1629580032.0 acme-tiny-5.0.1/.github/workflows/pull-request-tests.yml0000664000175000017500000000341600000000000023454 0ustar00user1user100000000000000name: Pull Request Tests on: [pull_request] jobs: test: name: Run tests strategy: matrix: include: - test-name: ubuntu-20.04-python-2.7 os: ubuntu-20.04 python-version: 2.7 - test-name: ubuntu-18.04-python-3.4 os: ubuntu-18.04 python-version: 3.4 - test-name: ubuntu-20.04-python-3.5 os: ubuntu-20.04 python-version: 3.5 - test-name: ubuntu-20.04-python-3.6 os: ubuntu-20.04 python-version: 3.6 - test-name: ubuntu-20.04-python-3.7 os: ubuntu-20.04 python-version: 3.7 - test-name: ubuntu-20.04-python-3.8 os: ubuntu-20.04 python-version: 3.8 runs-on: ${{ matrix.os }} steps: - name: Checkout repo uses: actions/checkout@v2 - name: Setup go uses: actions/setup-go@v1 with: go-version: 1.13 - name: Setup pebble run: | export PATH=$PATH:$(go env GOPATH)/bin go get -u github.com/letsencrypt/pebble/... cd $GOPATH/src/github.com/letsencrypt/pebble && go install ./... pebble -h || true - name: Setup python uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip pip install virtualenv pip install -U -r tests/requirements.txt - name: Run tests with coverage run: | export PEBBLE_BIN=$(go env GOPATH)/bin/pebble coverage run --source . --omit ./setup.py -m unittest tests - name: Print coverage report run: coverage report -m ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1629580032.0 acme-tiny-5.0.1/.github/workflows/staging-tests.yml0000664000175000017500000000430100000000000022440 0ustar00user1user100000000000000name: Staging Tests on: [workflow_dispatch] jobs: test: name: Run tests strategy: matrix: include: - test-name: ubuntu-20.04-python-2.7 os: ubuntu-20.04 python-version: 2.7 - test-name: ubuntu-18.04-python-3.4 os: ubuntu-18.04 python-version: 3.4 - test-name: ubuntu-20.04-python-3.5 os: ubuntu-20.04 python-version: 3.5 - test-name: ubuntu-20.04-python-3.6 os: ubuntu-20.04 python-version: 3.6 - test-name: ubuntu-20.04-python-3.7 os: ubuntu-20.04 python-version: 3.7 - test-name: ubuntu-20.04-python-3.8 os: ubuntu-20.04 python-version: 3.8 runs-on: ${{ matrix.os }} steps: - name: Checkout repo uses: actions/checkout@v2 - name: Setup python uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip pip install virtualenv pip install -U -r tests/requirements.txt - name: Mount staging.gethttpsforfree.com on local for serving challenge files env: STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }} run: | sudo apt install sshfs export SSHFS_KEYFILE=$(pwd)/sshfs_key.pem touch $SSHFS_KEYFILE chmod 600 $SSHFS_KEYFILE echo "$STAGING_SSH_KEY" > $SSHFS_KEYFILE mkdir -p /tmp/challenge-files nohup sshfs -o StrictHostKeyChecking=no,debug,IdentityFile=$SSHFS_KEYFILE -p 2299 challengeuser@staging.gethttpsforfree.com:/acme-challenge /tmp/challenge-files & sleep 10 ls -lah /tmp/challenge-files - name: Run tests using the Let's Encrypt staging server run: | export ACME_TINY_USE_STAGING="1" export ACME_TINY_DOMAIN="staging.gethttpsforfree.com" export ACME_TINY_SSHFS_CHALLENGE_DIR="/tmp/challenge-files" coverage run --source . --omit ./setup.py -m unittest tests - name: Print coverage report run: coverage report -m ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1448516700.0 acme-tiny-5.0.1/.gitignore0000644000175000017500000000127600000000000015522 0ustar00user1user100000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] # C extensions *.so # Distribution / packaging .Python env/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ *.egg-info/ .installed.cfg *.egg # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *,cover # Translations *.mo *.pot # Django stuff: *.log # Sphinx documentation docs/_build/ # PyBuilder target/ ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1448516700.0 acme-tiny-5.0.1/LICENSE0000644000175000017500000000207200000000000014532 0ustar00user1user100000000000000The MIT License (MIT) Copyright (c) 2015 Daniel Roesler Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1631387942.7756665 acme-tiny-5.0.1/PKG-INFO0000664000175000017500000000173400000000000014630 0ustar00user1user100000000000000Metadata-Version: 2.1 Name: acme-tiny Version: 5.0.1 Summary: A tiny script to issue and renew TLS certs from Let's Encrypt Home-page: https://github.com/diafygi/acme-tiny Author: Daniel Roesler Author-email: diafygi@gmail.com License: MIT Platform: UNKNOWN Classifier: Development Status :: 5 - Production/Stable Classifier: Intended Audience :: System Administrators Classifier: License :: OSI Approved :: MIT License Classifier: Operating System :: OS Independent Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 2 Classifier: Programming Language :: Python :: 2.7 Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3.3 Classifier: Programming Language :: Python :: 3.4 Classifier: Programming Language :: Python :: 3.5 Classifier: Programming Language :: Python :: 3.6 Classifier: Programming Language :: Python :: 3.7 Classifier: Programming Language :: Python :: 3.8 License-File: LICENSE UNKNOWN ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1629580032.0 acme-tiny-5.0.1/README.md0000664000175000017500000002162100000000000015007 0ustar00user1user100000000000000# acme-tiny [![Tests](https://github.com/diafygi/acme-tiny/actions/workflows/full-tests-with-coverage.yml/badge.svg)](https://github.com/diafygi/acme-tiny/actions/workflows/full-tests-with-coverage.yml) [![Coverage Status](https://coveralls.io/repos/github/diafygi/acme-tiny/badge.svg?branch=master)](https://coveralls.io/github/diafygi/acme-tiny?branch=master) This is a tiny, auditable script that you can throw on your server to issue and renew [Let's Encrypt](https://letsencrypt.org/) certificates. Since it has to be run on your server and have access to your private Let's Encrypt account key, I tried to make it as tiny as possible (currently less than 200 lines). The only prerequisites are python and openssl. **PLEASE READ THE SOURCE CODE! YOU MUST TRUST IT WITH YOUR PRIVATE ACCOUNT KEY!** ## Donate If this script is useful to you, please donate to the EFF. I don't work there, but they do fantastic work. [https://eff.org/donate/](https://eff.org/donate/) ## How to use this script If you already have a Let's Encrypt issued certificate and just want to renew, you should only have to do Steps 3 and 6. ### Step 1: Create a Let's Encrypt account private key (if you haven't already) You must have a public key registered with Let's Encrypt and sign your requests with the corresponding private key. If you don't understand what I just said, this script likely isn't for you! Please use the official Let's Encrypt [client](https://github.com/letsencrypt/letsencrypt). To accomplish this you need to initially create a key, that can be used by acme-tiny, to register an account for you and sign all following requests. ``` openssl genrsa 4096 > account.key ``` #### Use existing Let's Encrypt key Alternatively you can convert your key, previously generated by the original Let's Encrypt client. The private account key from the Let's Encrypt client is saved in the [JWK](https://tools.ietf.org/html/rfc7517) format. `acme-tiny` is using the PEM key format. To convert the key, you can use the tool [conversion script](https://gist.github.com/JonLundy/f25c99ee0770e19dc595) by JonLundy: ```sh # Download the script wget -O - "https://gist.githubusercontent.com/JonLundy/f25c99ee0770e19dc595/raw/6035c1c8938fae85810de6aad1ecf6e2db663e26/conv.py" > conv.py # Copy your private key to your working directory cp /etc/letsencrypt/accounts/acme-v01.api.letsencrypt.org/directory//private_key.json private_key.json # Create a DER encoded private key openssl asn1parse -noout -out private_key.der -genconf <(python2 conv.py private_key.json) # Convert to PEM openssl rsa -in private_key.der -inform der > account.key ``` ### Step 2: Create a certificate signing request (CSR) for your domains. The ACME protocol (what Let's Encrypt uses) requires a CSR file to be submitted to it, even for renewals. You can use the same CSR for multiple renewals. NOTE: you can't use your account private key as your domain private key! ``` # Generate a domain private key (if you haven't already) openssl genrsa 4096 > domain.key ``` ``` # For a single domain openssl req -new -sha256 -key domain.key -subj "/CN=yoursite.com" > domain.csr # For multiple domains (use this one if you want both www.yoursite.com and yoursite.com) openssl req -new -sha256 -key domain.key -subj "/" -addext "subjectAltName = DNS:yoursite.com, DNS:www.yoursite.com" > domain.csr # For multiple domains (same as above but works with openssl < 1.1.1) openssl req -new -sha256 -key domain.key -subj "/" -reqexts SAN -config <(cat /etc/ssl/openssl.cnf <(printf "[SAN]\nsubjectAltName=DNS:yoursite.com,DNS:www.yoursite.com")) > domain.csr ``` ### Step 3: Make your website host challenge files You must prove you own the domains you want a certificate for, so Let's Encrypt requires you host some files on them. This script will generate and write those files in the folder you specify, so all you need to do is make sure that this folder is served under the ".well-known/acme-challenge/" url path. NOTE: Let's Encrypt will perform a plain HTTP request to port 80 on your server, so you must serve the challenge files via HTTP (a redirect to HTTPS is fine too). ``` # Make some challenge folder (modify to suit your needs) mkdir -p /var/www/challenges/ ``` ```nginx # Example for nginx server { listen 80; server_name yoursite.com www.yoursite.com; location /.well-known/acme-challenge/ { alias /var/www/challenges/; try_files $uri =404; } ...the rest of your config } ``` ### Step 4: Get a signed certificate! Now that you have setup your server and generated all the needed files, run this script on your server with the permissions needed to write to the above folder and read your private account key and CSR. ``` # Run the script on your server python acme_tiny.py --account-key ./account.key --csr ./domain.csr --acme-dir /var/www/challenges/ > ./signed_chain.crt ``` ### Step 5: Install the certificate The signed https certificate chain that is output by this script can be used along with your private key to run an https server. You need to include them in the https settings in your web server's configuration. Here's an example on how to configure an nginx server: ```nginx server { listen 443 ssl; server_name yoursite.com www.yoursite.com; ssl_certificate /path/to/signed_chain.crt; ssl_certificate_key /path/to/domain.key; ssl_session_timeout 5m; ssl_protocols TLSv1.2; ssl_ciphers ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; ssl_session_cache shared:SSL:50m; ssl_dhparam /path/to/server.dhparam; ssl_prefer_server_ciphers on; ...the rest of your config } server { listen 80; server_name yoursite.com www.yoursite.com; location /.well-known/acme-challenge/ { alias /var/www/challenges/; try_files $uri =404; } ...the rest of your config } ``` ### Step 6: Setup an auto-renew cronjob Congrats! Your website is now using https! Unfortunately, Let's Encrypt certificates only last for 90 days, so you need to renew them often. No worries! It's automated! Just make a bash script and add it to your crontab (see below for example script). Example of a `renew_cert.sh`: ```sh #!/usr/bin/sh python /path/to/acme_tiny.py --account-key /path/to/account.key --csr /path/to/domain.csr --acme-dir /var/www/challenges/ > /path/to/signed_chain.crt.tmp || exit mv /path/to/signed_chain.crt.tmp /path/to/signed_chain.crt service nginx reload ``` ``` # Example line in your crontab (runs once per month) 0 0 1 * * /path/to/renew_cert.sh 2>> /var/log/acme_tiny.log ``` **NOTE:** Since Let's Encrypt's ACME v2 release (acme-tiny 4.0.0+), the intermediate certificate is included in the issued certificate download, so you no longer have to independently download the intermediate certificate and concatenate it to your signed certificate. If you have an shell script or Makefile using acme-tiny <4.0 (e.g. before 2018-03-17) with acme-tiny 4.0.0+, then you may be adding the intermediate certificate to your signed_chain.crt twice (which [causes issues with at least GnuTLS 3.7.0](https://gitlab.com/gnutls/gnutls/-/issues/1131) besides making the certificate slightly larger than it needs to be). To fix, simply remove the bash code where you're downloading the intermediate and adding it to the acme-tiny certificate output. ## Permissions The biggest problem you'll likely come across while setting up and running this script is permissions. You want to limit access to your account private key and challenge web folder as much as possible. I'd recommend creating a user specifically for handling this script, the account private key, and the challenge folder. Then add the ability for that user to write to your installed certificate file (e.g. `/path/to/signed_chain.crt`) and reload your webserver. That way, the cron script will do its thing, overwrite your old certificate, and reload your webserver without having permission to do anything else. **BE SURE TO:** * Backup your account private key (e.g. `account.key`) * Don't allow this script to be able to read your domain private key! * Don't allow this script to be run as root! ## Staging Environment Let's Encrypt recommends testing new configurations against their staging servers, so when testing out your new setup, you can use `--directory-url https://acme-staging-v02.api.letsencrypt.org/directory` to issue fake test certificates instead of real ones from Let's Encrypt's production servers. See [https://letsencrypt.org/docs/staging-environment/](https://letsencrypt.org/docs/staging-environment/) for more details. ## Feedback/Contributing This project has a very, very limited scope and codebase. I'm happy to receive bug reports and pull requests, but please don't add any new features. This script must stay under 200 lines of code to ensure it can be easily audited by anyone who wants to run it. If you want to add features for your own setup to make things easier for you, please do! It's open source, so feel free to fork it and modify as necessary. ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1631387942.7756665 acme-tiny-5.0.1/acme_tiny.egg-info/0000775000175000017500000000000000000000000017170 5ustar00user1user100000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1631387942.0 acme-tiny-5.0.1/acme_tiny.egg-info/PKG-INFO0000644000175000017500000000173400000000000020270 0ustar00user1user100000000000000Metadata-Version: 2.1 Name: acme-tiny Version: 5.0.1 Summary: A tiny script to issue and renew TLS certs from Let's Encrypt Home-page: https://github.com/diafygi/acme-tiny Author: Daniel Roesler Author-email: diafygi@gmail.com License: MIT Platform: UNKNOWN Classifier: Development Status :: 5 - Production/Stable Classifier: Intended Audience :: System Administrators Classifier: License :: OSI Approved :: MIT License Classifier: Operating System :: OS Independent Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 2 Classifier: Programming Language :: Python :: 2.7 Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3.3 Classifier: Programming Language :: Python :: 3.4 Classifier: Programming Language :: Python :: 3.5 Classifier: Programming Language :: Python :: 3.6 Classifier: Programming Language :: Python :: 3.7 Classifier: Programming Language :: Python :: 3.8 License-File: LICENSE UNKNOWN ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1631387942.0 acme-tiny-5.0.1/acme_tiny.egg-info/SOURCES.txt0000644000175000017500000000072300000000000021054 0ustar00user1user100000000000000.gitignore LICENSE README.md acme_tiny.py setup.cfg setup.py .github/workflows/full-tests-with-coverage.yml .github/workflows/pull-request-tests.yml .github/workflows/staging-tests.yml acme_tiny.egg-info/PKG-INFO acme_tiny.egg-info/SOURCES.txt acme_tiny.egg-info/dependency_links.txt acme_tiny.egg-info/entry_points.txt acme_tiny.egg-info/top_level.txt tests/README.md tests/__init__.py tests/requirements.txt tests/test_install.py tests/test_module.py tests/utils.py././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1631387942.0 acme-tiny-5.0.1/acme_tiny.egg-info/dependency_links.txt0000644000175000017500000000000100000000000023234 0ustar00user1user100000000000000 ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1631387942.0 acme-tiny-5.0.1/acme_tiny.egg-info/entry_points.txt0000644000175000017500000000005600000000000022465 0ustar00user1user100000000000000[console_scripts] acme-tiny = acme_tiny:main ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1631387942.0 acme-tiny-5.0.1/acme_tiny.egg-info/top_level.txt0000644000175000017500000000001200000000000021711 0ustar00user1user100000000000000acme_tiny ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1631387722.0 acme-tiny-5.0.1/acme_tiny.py0000775000175000017500000002702700000000000016063 0ustar00user1user100000000000000#!/usr/bin/env python3 # Copyright Daniel Roesler, under MIT license, see LICENSE at github.com/diafygi/acme-tiny import argparse, subprocess, json, os, sys, base64, binascii, time, hashlib, re, copy, textwrap, logging try: from urllib.request import urlopen, Request # Python 3 except ImportError: # pragma: no cover from urllib2 import urlopen, Request # Python 2 DEFAULT_CA = "https://acme-v02.api.letsencrypt.org" # DEPRECATED! USE DEFAULT_DIRECTORY_URL INSTEAD DEFAULT_DIRECTORY_URL = "https://acme-v02.api.letsencrypt.org/directory" LOGGER = logging.getLogger(__name__) LOGGER.addHandler(logging.StreamHandler()) LOGGER.setLevel(logging.INFO) def get_crt(account_key, csr, acme_dir, log=LOGGER, CA=DEFAULT_CA, disable_check=False, directory_url=DEFAULT_DIRECTORY_URL, contact=None, check_port=None): directory, acct_headers, alg, jwk = None, None, None, None # global variables # helper functions - base64 encode for jose spec def _b64(b): return base64.urlsafe_b64encode(b).decode('utf8').replace("=", "") # helper function - run external commands def _cmd(cmd_list, stdin=None, cmd_input=None, err_msg="Command Line Error"): proc = subprocess.Popen(cmd_list, stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE) out, err = proc.communicate(cmd_input) if proc.returncode != 0: raise IOError("{0}\n{1}".format(err_msg, err)) return out # helper function - make request and automatically parse json response def _do_request(url, data=None, err_msg="Error", depth=0): try: resp = urlopen(Request(url, data=data, headers={"Content-Type": "application/jose+json", "User-Agent": "acme-tiny"})) resp_data, code, headers = resp.read().decode("utf8"), resp.getcode(), resp.headers except IOError as e: resp_data = e.read().decode("utf8") if hasattr(e, "read") else str(e) code, headers = getattr(e, "code", None), {} try: resp_data = json.loads(resp_data) # try to parse json results except ValueError: pass # ignore json parsing errors if depth < 100 and code == 400 and resp_data['type'] == "urn:ietf:params:acme:error:badNonce": raise IndexError(resp_data) # allow 100 retrys for bad nonces if code not in [200, 201, 204]: raise ValueError("{0}:\nUrl: {1}\nData: {2}\nResponse Code: {3}\nResponse: {4}".format(err_msg, url, data, code, resp_data)) return resp_data, code, headers # helper function - make signed requests def _send_signed_request(url, payload, err_msg, depth=0): payload64 = "" if payload is None else _b64(json.dumps(payload).encode('utf8')) new_nonce = _do_request(directory['newNonce'])[2]['Replay-Nonce'] protected = {"url": url, "alg": alg, "nonce": new_nonce} protected.update({"jwk": jwk} if acct_headers is None else {"kid": acct_headers['Location']}) protected64 = _b64(json.dumps(protected).encode('utf8')) protected_input = "{0}.{1}".format(protected64, payload64).encode('utf8') out = _cmd(["openssl", "dgst", "-sha256", "-sign", account_key], stdin=subprocess.PIPE, cmd_input=protected_input, err_msg="OpenSSL Error") data = json.dumps({"protected": protected64, "payload": payload64, "signature": _b64(out)}) try: return _do_request(url, data=data.encode('utf8'), err_msg=err_msg, depth=depth) except IndexError: # retry bad nonces (they raise IndexError) return _send_signed_request(url, payload, err_msg, depth=(depth + 1)) # helper function - poll until complete def _poll_until_not(url, pending_statuses, err_msg): result, t0 = None, time.time() while result is None or result['status'] in pending_statuses: assert (time.time() - t0 < 3600), "Polling timeout" # 1 hour timeout time.sleep(0 if result is None else 2) result, _, _ = _send_signed_request(url, None, err_msg) return result # parse account key to get public key log.info("Parsing account key...") out = _cmd(["openssl", "rsa", "-in", account_key, "-noout", "-text"], err_msg="OpenSSL Error") pub_pattern = r"modulus:[\s]+?00:([a-f0-9\:\s]+?)\npublicExponent: ([0-9]+)" pub_hex, pub_exp = re.search(pub_pattern, out.decode('utf8'), re.MULTILINE|re.DOTALL).groups() pub_exp = "{0:x}".format(int(pub_exp)) pub_exp = "0{0}".format(pub_exp) if len(pub_exp) % 2 else pub_exp alg, jwk = "RS256", { "e": _b64(binascii.unhexlify(pub_exp.encode("utf-8"))), "kty": "RSA", "n": _b64(binascii.unhexlify(re.sub(r"(\s|:)", "", pub_hex).encode("utf-8"))), } accountkey_json = json.dumps(jwk, sort_keys=True, separators=(',', ':')) thumbprint = _b64(hashlib.sha256(accountkey_json.encode('utf8')).digest()) # find domains log.info("Parsing CSR...") out = _cmd(["openssl", "req", "-in", csr, "-noout", "-text"], err_msg="Error loading {0}".format(csr)) domains = set([]) common_name = re.search(r"Subject:.*? CN\s?=\s?([^\s,;/]+)", out.decode('utf8')) if common_name is not None: domains.add(common_name.group(1)) subject_alt_names = re.search(r"X509v3 Subject Alternative Name: (?:critical)?\n +([^\n]+)\n", out.decode('utf8'), re.MULTILINE|re.DOTALL) if subject_alt_names is not None: for san in subject_alt_names.group(1).split(", "): if san.startswith("DNS:"): domains.add(san[4:]) log.info(u"Found domains: {0}".format(", ".join(domains))) # get the ACME directory of urls log.info("Getting directory...") directory_url = CA + "/directory" if CA != DEFAULT_CA else directory_url # backwards compatibility with deprecated CA kwarg directory, _, _ = _do_request(directory_url, err_msg="Error getting directory") log.info("Directory found!") # create account, update contact details (if any), and set the global key identifier log.info("Registering account...") reg_payload = {"termsOfServiceAgreed": True} if contact is None else {"termsOfServiceAgreed": True, "contact": contact} account, code, acct_headers = _send_signed_request(directory['newAccount'], reg_payload, "Error registering") log.info("{0} Account ID: {1}".format("Registered!" if code == 201 else "Already registered!", acct_headers['Location'])) if contact is not None: account, _, _ = _send_signed_request(acct_headers['Location'], {"contact": contact}, "Error updating contact details") log.info("Updated contact details:\n{0}".format("\n".join(account['contact']))) # create a new order log.info("Creating new order...") order_payload = {"identifiers": [{"type": "dns", "value": d} for d in domains]} order, _, order_headers = _send_signed_request(directory['newOrder'], order_payload, "Error creating new order") log.info("Order created!") # get the authorizations that need to be completed for auth_url in order['authorizations']: authorization, _, _ = _send_signed_request(auth_url, None, "Error getting challenges") domain = authorization['identifier']['value'] # skip if already valid if authorization['status'] == "valid": log.info("Already verified: {0}, skipping...".format(domain)) continue log.info("Verifying {0}...".format(domain)) # find the http-01 challenge and write the challenge file challenge = [c for c in authorization['challenges'] if c['type'] == "http-01"][0] token = re.sub(r"[^A-Za-z0-9_\-]", "_", challenge['token']) keyauthorization = "{0}.{1}".format(token, thumbprint) wellknown_path = os.path.join(acme_dir, token) with open(wellknown_path, "w") as wellknown_file: wellknown_file.write(keyauthorization) # check that the file is in place try: wellknown_url = "http://{0}{1}/.well-known/acme-challenge/{2}".format(domain, "" if check_port is None else ":{0}".format(check_port), token) assert (disable_check or _do_request(wellknown_url)[0] == keyauthorization) except (AssertionError, ValueError) as e: raise ValueError("Wrote file to {0}, but couldn't download {1}: {2}".format(wellknown_path, wellknown_url, e)) # say the challenge is done _send_signed_request(challenge['url'], {}, "Error submitting challenges: {0}".format(domain)) authorization = _poll_until_not(auth_url, ["pending"], "Error checking challenge status for {0}".format(domain)) if authorization['status'] != "valid": raise ValueError("Challenge did not pass for {0}: {1}".format(domain, authorization)) os.remove(wellknown_path) log.info("{0} verified!".format(domain)) # finalize the order with the csr log.info("Signing certificate...") csr_der = _cmd(["openssl", "req", "-in", csr, "-outform", "DER"], err_msg="DER Export Error") _send_signed_request(order['finalize'], {"csr": _b64(csr_der)}, "Error finalizing order") # poll the order to monitor when it's done order = _poll_until_not(order_headers['Location'], ["pending", "processing"], "Error checking order status") if order['status'] != "valid": raise ValueError("Order failed: {0}".format(order)) # download the certificate certificate_pem, _, _ = _send_signed_request(order['certificate'], None, "Certificate download failed") log.info("Certificate signed!") return certificate_pem def main(argv=None): parser = argparse.ArgumentParser( formatter_class=argparse.RawDescriptionHelpFormatter, description=textwrap.dedent("""\ This script automates the process of getting a signed TLS certificate from Let's Encrypt using the ACME protocol. It will need to be run on your server and have access to your private account key, so PLEASE READ THROUGH IT! It's only ~200 lines, so it won't take long. Example Usage: python acme_tiny.py --account-key ./account.key --csr ./domain.csr --acme-dir /usr/share/nginx/html/.well-known/acme-challenge/ > signed_chain.crt """) ) parser.add_argument("--account-key", required=True, help="path to your Let's Encrypt account private key") parser.add_argument("--csr", required=True, help="path to your certificate signing request") parser.add_argument("--acme-dir", required=True, help="path to the .well-known/acme-challenge/ directory") parser.add_argument("--quiet", action="store_const", const=logging.ERROR, help="suppress output except for errors") parser.add_argument("--disable-check", default=False, action="store_true", help="disable checking if the challenge file is hosted correctly before telling the CA") parser.add_argument("--directory-url", default=DEFAULT_DIRECTORY_URL, help="certificate authority directory url, default is Let's Encrypt") parser.add_argument("--ca", default=DEFAULT_CA, help="DEPRECATED! USE --directory-url INSTEAD!") parser.add_argument("--contact", metavar="CONTACT", default=None, nargs="*", help="Contact details (e.g. mailto:aaa@bbb.com) for your account-key") parser.add_argument("--check-port", metavar="PORT", default=None, help="what port to use when self-checking the challenge file, default is port 80") args = parser.parse_args(argv) LOGGER.setLevel(args.quiet or LOGGER.level) signed_crt = get_crt(args.account_key, args.csr, args.acme_dir, log=LOGGER, CA=args.ca, disable_check=args.disable_check, directory_url=args.directory_url, contact=args.contact, check_port=args.check_port) sys.stdout.write(signed_crt) if __name__ == "__main__": # pragma: no cover main(sys.argv[1:]) ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1631387942.7796667 acme-tiny-5.0.1/setup.cfg0000644000175000017500000000010000000000000015334 0ustar00user1user100000000000000[wheel] universal = True [egg_info] tag_build = tag_date = 0 ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1629580032.0 acme-tiny-5.0.1/setup.py0000664000175000017500000000221600000000000015241 0ustar00user1user100000000000000from setuptools import setup setup( name="acme-tiny", use_scm_version=True, url="https://github.com/diafygi/acme-tiny", author="Daniel Roesler", author_email="diafygi@gmail.com", description="A tiny script to issue and renew TLS certs from Let's Encrypt", license="MIT", py_modules=['acme_tiny'], entry_points={'console_scripts': [ 'acme-tiny = acme_tiny:main', ]}, setup_requires=['setuptools_scm'], classifiers = [ 'Development Status :: 5 - Production/Stable', 'Intended Audience :: System Administrators', 'License :: OSI Approved :: MIT License', 'Operating System :: OS Independent', 'Programming Language :: Python', 'Programming Language :: Python :: 2', 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.3', 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', ] ) ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1631387942.7756665 acme-tiny-5.0.1/tests/0000775000175000017500000000000000000000000014670 5ustar00user1user100000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1629582196.0 acme-tiny-5.0.1/tests/README.md0000664000175000017500000000763500000000000016162 0ustar00user1user100000000000000# How to test acme-tiny Testing acme-tiny requires a bit of setup since it needs to interact with a local Let's Encrypt CA test server. This readme explains how to set up your local environment so you can run `acme-tiny` tests yourself. ## Setup instructions (default) In the default test setup, we use [pebble](https://github.com/letsencrypt/pebble) as the mock Let's Encrypt CA server on your local computer. So you need to install that before running the test suite. 1. Install the Let's Encrypt test server: `pebble` (instructions below are for Ubuntu 20.04, adjust as needed) * `sudo apt install golang` * `go get -u github.com/letsencrypt/pebble/...` * `cd ~/go/src/github.com/letsencrypt/pebble && go install ./...` * `~/go/bin/pebble -h` (should print out pebble usage help) 2. Setup a virtual environment for python: * `virtualenv -p python3 /tmp/venv` (creates the virtualenv) * `source /tmp/venv/bin/activate` (starts using the virtualenv) 3. Install `acme-tiny` test dependencies: * `cd /path/to/acme-tiny` * `pip install -U -r tests/requirements.txt` 4. Run the test suite on your local. * `cd /path/to/acme-tiny` * `unset ACME_TINY_USE_STAGING` (optional, if set previously to use staging) * `unset ACME_TINY_DOMAIN` (optional, if set previously to use staging) * `export ACME_TINY_PEBBLE_BIN="..."` (optional, if different from `"$HOME/go/bin/pebble"`) * `coverage erase` (removes any previous coverage data files) * `coverage run --source . --omit ./setup.py -m unittest tests` (runs the test suite) * `coverage report -m` (optional, prints out coverage summary in console) * `coverage html` (optional, generates html coverage report you can browse at `htmlcov/index.html`) ## Setup instructions (staging) We also allow running the test suite against the official Let's Encrypt [staging](https://letsencrypt.org/docs/staging-environment/) server. Since the staging server is run by Let's Encrypt, you need to actually host a real domain an serve real challenge files. The simplest way to do this is to mount your remote server's static challenge file directory (see example instructions below). 1. Run a static server with a real domain (e.g. `test.mydomain.com`) with a challenge directory (instructions below are for Ubuntu 20.04, adjust as needed). * `ssh ubuntu@test.mydomain.com` (log into your server) * `mkdir -p /tmp/testfiles/.well-known/acme-challenge` (make the ACME challenge file directory) * `cd /tmp/testfiles` (go to the test file base directory) * `sudo python3 -m http.server 80 --bind 0.0.0.0` (start listening on port 80, NOTE: needs to run as root) * Alternatively, if you are already have a web server running on port 80, adjust that server's config to serve files statically from your test directory. 2. Mount your server's challenge directory on your local system (instructions below are for Ubuntu 20.04, adjust as needed). * `sudo apt install sshfs` (if not already done, install sshfs) * `sshfs ubuntu@test.mydomain.com:/tmp/testfiles/.well-known/acme-challenge /tmp/challenge-files` 3. Setup a virtual environment for python: * `virtualenv -p python3 /tmp/venv` (creates the virtualenv) * `source /tmp/venv/bin/activate` (starts using the virtualenv) 4. Install `acme-tiny` test dependencies: * `cd /path/to/acme-tiny` * `pip install -U -r tests/requirements.txt` 5. Run the test suite on your local. * `cd /path/to/acme-tiny` * `export ACME_TINY_USE_STAGING="1"` * `export ACME_TINY_DOMAIN="test.mydomain.com"` * `export ACME_TINY_SSHFS_CHALLENGE_DIR="/tmp/challenge-files"` * `coverage erase` (removes any previous coverage data files) * `coverage run --source . --omit ./setup.py -m unittest tests` (runs the test suite) * `coverage report -m` (optional, prints out coverage summary in console) * `coverage html` (optional, generates html coverage report you can browse at `htmlcov/index.html`) 6. When done, unmount the remote directory * `umount /tmp/challenge-files` ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1627963385.0 acme-tiny-5.0.1/tests/__init__.py0000644000175000017500000000011200000000000016771 0ustar00user1user100000000000000from .test_module import TestModule from .test_install import TestInstall ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1629580032.0 acme-tiny-5.0.1/tests/requirements.txt0000664000175000017500000000001100000000000020144 0ustar00user1user100000000000000coverage ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1521178488.0 acme-tiny-5.0.1/tests/test_install.py0000644000175000017500000000116400000000000017747 0ustar00user1user100000000000000import unittest import os import tempfile import shutil import subprocess class TestInstall(unittest.TestCase): def setUp(self): self.tempdir = tempfile.mkdtemp() subprocess.check_call(["virtualenv", self.tempdir]) def tearDown(self): shutil.rmtree(self.tempdir) def virtualenv_bin(self, cmd): return os.path.join(self.tempdir, "bin", cmd) def test_install(self): subprocess.check_call([self.virtualenv_bin("python"), "setup.py", "install"]) def test_cli(self): self.test_install() subprocess.check_call([self.virtualenv_bin("acme-tiny"), "-h"]) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1629597348.0 acme-tiny-5.0.1/tests/test_module.py0000664000175000017500000004567500000000000017607 0ustar00user1user100000000000000import os import sys import json import time import shutil import logging import unittest import tempfile from subprocess import Popen, PIPE try: from urllib.request import urlopen, Request # Python 3 except ImportError: # pragma: no cover from urllib2 import urlopen, Request # Python 2 try: from StringIO import StringIO # Python 2 except ImportError: # pragma: no cover from io import StringIO # Python 3 import acme_tiny from . import utils # test settings based on environmental variables PEBBLE_BIN = os.getenv("ACME_TINY_PEBBLE_BIN") or "{}/go/bin/pebble".format(os.getenv("HOME")) # default pebble install path DOMAIN = os.getenv("ACME_TINY_DOMAIN") or "local.gethttpsforfree.com" # default to domain that resolves to 127.0.0.1 USE_STAGING = bool(os.getenv("ACME_TINY_USE_STAGING")) # default to false SSHFS_CHALLENGE_DIR = os.getenv("ACME_TINY_SSHFS_CHALLENGE_DIR") # default to None (only used if USE_STAGING is True) class TestModule(unittest.TestCase): """ Tests for acme_tiny.py functionality itself """ def setUp(self): """ Set up ACME server for each test (or use Let's Encrypt's staging server) """ # create new account keys every test self.KEYS = utils.gen_keys(DOMAIN) # use Let's Encrypt staging server if USE_STAGING: # pragma: no cover os.unsetenv("SSL_CERT_FILE") # use the default ssl trust store # config references self.tempdir = SSHFS_CHALLENGE_DIR self.check_port = "80" self.DIR_URL = "https://acme-staging-v02.api.letsencrypt.org/directory" # staging server errors self.account_key_error = "certificate public key must be different than account key" self.ca_issued_string = "(STAGING) Let's Encrypt" self.bad_character_error = "Domain name contains an invalid character" # default to using pebble server else: # config references self.tempdir = None # generated below self.DIR_URL = "https://localhost:14000/dir" self._pebble_server, self._pebble_config = utils.setup_pebble(PEBBLE_BIN) self.check_port = str(self._pebble_config['pebble']['httpPort']) self._challenge_file_server, self._base_tempdir, self.tempdir = utils.setup_local_fileserver(self.check_port, pebble_proc=self._pebble_server) # pebble server errors self.account_key_error = "CSR contains a public key for a known account" self.ca_issued_string = "Pebble Intermediate CA" self.bad_character_error = "Order included DNS identifier with a value containing an illegal character" def tearDown(self): """ Shut down sub processes (pebble, etc.) """ # only need to shut down stuff if using local servers (pebble) if not USE_STAGING: self._pebble_server.terminate() self._pebble_server.wait() os.remove(self._pebble_config['pebble']['certificate']) os.remove(self._pebble_config['pebble']['privateKey']) self._challenge_file_server.terminate() self._challenge_file_server.wait() shutil.rmtree(self._base_tempdir) def test_success_domain(self): """ Successfully issue a certificate via subject alt name """ old_stdout = sys.stdout sys.stdout = StringIO() result = acme_tiny.main([ "--account-key", self.KEYS['account_key'].name, "--csr", self.KEYS['domain_csr'].name, "--acme-dir", self.tempdir, "--directory-url", self.DIR_URL, "--check-port", self.check_port, ]) sys.stdout.seek(0) crt = sys.stdout.read().encode("utf8") sys.stdout = old_stdout out, err = Popen(["openssl", "x509", "-text", "-noout"], stdin=PIPE, stdout=PIPE, stderr=PIPE).communicate(crt) self.assertIn(self.ca_issued_string, out.decode("utf8")) def test_skip_valid_authorizations(self): """ Authorizations that are already valid should be skipped """ # issue a valid cert self.test_success_domain() # add a logging handler that captures the info log output log_output = StringIO() debug_handler = logging.StreamHandler(log_output) acme_tiny.LOGGER.addHandler(debug_handler) # issue the cert again, where challenges should already be valid old_stdout = sys.stdout sys.stdout = StringIO() result = acme_tiny.main([ "--account-key", self.KEYS['account_key'].name, "--csr", self.KEYS['domain_csr'].name, "--acme-dir", self.tempdir, "--directory-url", self.DIR_URL, "--check-port", self.check_port, ]) sys.stdout.seek(0) crt = sys.stdout.read().encode("utf8") sys.stdout = old_stdout log_output.seek(0) log_string = log_output.read().encode("utf8") # remove logging capture acme_tiny.LOGGER.removeHandler(debug_handler) # should say the domain is already verified self.assertIn("Already verified: {0}, skipping...".format(DOMAIN), log_string.decode("utf8")) def test_success_cli(self): """ Successfully issue a certificate via command line interface """ crt, err = Popen([ "python", "acme_tiny.py", "--account-key", self.KEYS['account_key'].name, "--csr", self.KEYS['domain_csr'].name, "--acme-dir", self.tempdir, "--directory-url", self.DIR_URL, "--check-port", self.check_port, ], stdout=PIPE, stderr=PIPE).communicate() out, err = Popen(["openssl", "x509", "-text", "-noout"], stdin=PIPE, stdout=PIPE, stderr=PIPE).communicate(crt) self.assertIn(self.ca_issued_string, out.decode("utf8")) def test_missing_account_key(self): """ OpenSSL throws an error when the account key is missing """ try: result = acme_tiny.main([ "--account-key", "/foo/bar", "--csr", self.KEYS['domain_csr'].name, "--acme-dir", self.tempdir, "--directory-url", self.DIR_URL, "--check-port", self.check_port, ]) except Exception as e: result = e self.assertIsInstance(result, IOError) self.assertIn("unable to load Private Key", result.args[0]) def test_missing_csr(self): """ OpenSSL throws an error when the CSR is missing """ try: result = acme_tiny.main([ "--account-key", self.KEYS['account_key'].name, "--csr", "/foo/bar", "--acme-dir", self.tempdir, "--directory-url", self.DIR_URL, "--check-port", self.check_port, ]) except Exception as e: result = e self.assertIsInstance(result, IOError) self.assertIn("Error loading /foo/bar", result.args[0]) def test_invalid_domain(self): """ Let's Encrypt rejects invalid domains """ try: result = acme_tiny.main([ "--account-key", self.KEYS['account_key'].name, "--csr", self.KEYS['invalid_csr'].name, "--acme-dir", self.tempdir, "--directory-url", self.DIR_URL, "--check-port", self.check_port, ]) except Exception as e: result = e self.assertIsInstance(result, ValueError) self.assertIn(self.bad_character_error, result.args[0]) def test_nonexistent_domain(self): """ Should be unable verify a nonexistent domain """ try: result = acme_tiny.main([ "--account-key", self.KEYS['account_key'].name, "--csr", self.KEYS['nonexistent_csr'].name, "--acme-dir", self.tempdir, "--directory-url", self.DIR_URL, "--check-port", self.check_port, ]) except Exception as e: result = e self.assertIsInstance(result, ValueError) self.assertIn("but couldn't download", result.args[0]) def test_account_key_domain(self): """ Can't use the account key for the CSR """ try: result = acme_tiny.main([ "--account-key", self.KEYS['account_key'].name, "--csr", self.KEYS['account_csr'].name, "--acme-dir", self.tempdir, "--directory-url", self.DIR_URL, "--check-port", self.check_port, ]) except Exception as e: result = e self.assertIsInstance(result, ValueError) self.assertIn(self.account_key_error, result.args[0]) def test_contact(self): """ Make sure optional contact details can be set """ # add a logging handler that captures the info log output log_output = StringIO() debug_handler = logging.StreamHandler(log_output) acme_tiny.LOGGER.addHandler(debug_handler) # call acme_tiny with new contact details old_stdout = sys.stdout sys.stdout = StringIO() result = acme_tiny.main([ "--account-key", self.KEYS['account_key'].name, "--csr", self.KEYS['domain_csr'].name, "--acme-dir", self.tempdir, "--directory-url", self.DIR_URL, "--check-port", self.check_port, "--contact", "mailto:devteam@gethttpsforfree.com", "mailto:boss@gethttpsforfree.com", ]) sys.stdout.seek(0) crt = sys.stdout.read().encode("utf8") sys.stdout = old_stdout log_output.seek(0) log_string = log_output.read().encode("utf8") # make sure the certificate was issued and the contact details were updated out, err = Popen(["openssl", "x509", "-text", "-noout"], stdin=PIPE, stdout=PIPE, stderr=PIPE).communicate(crt) self.assertIn(self.ca_issued_string, out.decode("utf8")) self.assertTrue(( # can be in either order "Updated contact details:\nmailto:devteam@gethttpsforfree.com\nmailto:boss@gethttpsforfree.com" in log_string.decode("utf8") or "Updated contact details:\nmailto:boss@gethttpsforfree.com\nmailto:devteam@gethttpsforfree.com" in log_string.decode("utf8") )) # remove logging capture acme_tiny.LOGGER.removeHandler(debug_handler) def test_challenge_failure(self): """ Raises error if challenge doesn't pass """ # man-in-the-middle ACME requests to modify valid challenges so we raise that exception def urlopenMITM(*args, **kwargs): resp = urlopenOriginal(*args, **kwargs) resp._orig_read = resp.read() # modify valid challenges and authorizations to invalid try: resp_json = json.loads(resp._orig_read.decode("utf8")) if ( len(resp_json.get("challenges", [])) == 1 and resp_json['challenges'][0]['status'] == "valid" and resp_json['status'] == "valid" ): resp_json['challenges'][0]['status'] = "invalid" resp_json['status'] = "invalid" resp._orig_read = json.dumps(resp_json).encode("utf8") except ValueError: pass # serve up modified response when read def multi_read(): return resp._orig_read resp.read = multi_read return resp # call acme-tiny with MITM'd urlopen urlopenOriginal = acme_tiny.urlopen acme_tiny.urlopen = urlopenMITM try: acme_tiny.main([ "--account-key", self.KEYS['account_key'].name, "--csr", self.KEYS['domain_csr'].name, "--acme-dir", self.tempdir, "--directory-url", self.DIR_URL, "--check-port", self.check_port, ]) except ValueError as e: result = e acme_tiny.urlopen = urlopenOriginal # should raise error that challenge didn't pass self.assertIn("Challenge did not pass for", result.args[0]) def test_malicious_challenge_token(self): """ Raises error if malicious challenge token is provided by the CA """ # assume the CA wants to try to fool you into serving up your password file malicious_token = "../../../../etc/passwd" cleaned_token = "____________etc_passwd" # man-in-the-middle ACME requests to modify the challenge token to something malicious def urlopenMITM(*args, **kwargs): resp = urlopenOriginal(*args, **kwargs) resp._orig_read = resp.read() try: resp_json = json.loads(resp._orig_read.decode("utf8")) if len([c for c in resp_json.get("challenges", []) if c['type'] == "http-01"]) == 1: challenge = [c for c in resp_json['challenges'] if c['type'] == "http-01"][0] challenge['token'] = malicious_token resp._orig_read = json.dumps(resp_json).encode("utf8") except ValueError: pass # serve up modified response when read def multi_read(): return resp._orig_read resp.read = multi_read return resp # call acme-tiny with MITM'd urlopen urlopenOriginal = acme_tiny.urlopen acme_tiny.urlopen = urlopenMITM try: acme_tiny.main([ "--account-key", self.KEYS['account_key'].name, "--csr", self.KEYS['domain_csr'].name, "--acme-dir", self.tempdir, "--directory-url", self.DIR_URL, "--check-port", self.check_port, ]) except ValueError as e: result = e acme_tiny.urlopen = urlopenOriginal # should raise error that challenge didn't pass self.assertIn("Challenge did not pass for", result.args[0]) # challenge file actually saved as a cleaned version resp = urlopen(Request("http://{0}:{1}/.well-known/acme-challenge/{2}".format(DOMAIN, self.check_port, cleaned_token))) token_data = resp.read().decode("utf8") self.assertIn(cleaned_token, token_data) def test_order_failure(self): """ Raises error if order doesn't complete """ # man-in-the-middle ACME requests to modify valid orders so we raise that exception def urlopenMITM(*args, **kwargs): resp = urlopenOriginal(*args, **kwargs) resp._orig_read = resp.read() # modify valid orders to invalid try: resp_json = json.loads(resp._orig_read.decode("utf8")) if ( resp_json.get("finalize", None) is not None and resp_json.get("status", None) == "valid" ): resp_json['status'] = "invalid" resp._orig_read = json.dumps(resp_json).encode("utf8") except ValueError: pass # serve up modified response when read def multi_read(): return resp._orig_read resp.read = multi_read return resp # call acme-tiny with MITM'd urlopen urlopenOriginal = acme_tiny.urlopen acme_tiny.urlopen = urlopenMITM try: acme_tiny.main([ "--account-key", self.KEYS['account_key'].name, "--csr", self.KEYS['domain_csr'].name, "--acme-dir", self.tempdir, "--directory-url", self.DIR_URL, "--check-port", self.check_port, ]) except ValueError as e: result = e acme_tiny.urlopen = urlopenOriginal # should raise error that challenge didn't pass self.assertIn("Order failed", result.args[0]) ########################### ## Pebble-specific tests ## ########################### @unittest.skipIf(USE_STAGING, "only checked on pebble server since staging can't have nonce retries set") def test_nonce_retry(self): """ Still works when lots of nonce retries """ # kill current pebble server self._pebble_server.terminate() self._pebble_server.wait() os.remove(self._pebble_config['pebble']['certificate']) os.remove(self._pebble_config['pebble']['privateKey']) # restart with new bad nonce rate self._pebble_server, self._pebble_config = utils.setup_pebble(PEBBLE_BIN, bad_nonces=90) # normal success test self.test_success_domain() @unittest.skipIf(USE_STAGING, "only checked on pebble server since ") def test_pebble_doesnt_support_cn_domains(self): """ Test that pebble server doesn't support CN subject domains """ try: result = acme_tiny.main([ "--account-key", self.KEYS['account_key'].name, "--csr", self.KEYS['cn_csr'].name, "--acme-dir", self.tempdir, "--directory-url", self.DIR_URL, "--check-port", self.check_port, ]) except Exception as e: result = e self.assertIsInstance(result, ValueError) self.assertIn("Order includes different number of DNSnames identifiers than CSR specifies", result.args[0]) ############################ ## Staging-specific tests ## ############################ @unittest.skipIf((not USE_STAGING), "only checked on staging since pebble doesn't support CN names") def test_success_cn(self): # pragma: no cover """ Successfully issue a certificate via common name """ old_stdout = sys.stdout sys.stdout = StringIO() result = acme_tiny.main([ "--account-key", self.KEYS['account_key'].name, "--csr", self.KEYS['cn_csr'].name, "--acme-dir", self.tempdir, "--directory-url", self.DIR_URL, #"--check-port", self.check_port, # defaults to port 80 anyway, so test that the default works ]) sys.stdout.seek(0) crt = sys.stdout.read().encode("utf8") sys.stdout = old_stdout out, err = Popen(["openssl", "x509", "-text", "-noout"], stdin=PIPE, stdout=PIPE, stderr=PIPE).communicate(crt) self.assertIn(self.ca_issued_string, out.decode("utf8")) @unittest.skipIf((not USE_STAGING), "only checked on staging since pebble doesn't check for weak keys") def test_weak_key(self): # pragma: no cover """ Let's Encrypt rejects weak keys """ try: result = acme_tiny.main([ "--account-key", self.KEYS['weak_key'].name, "--csr", self.KEYS['domain_csr'].name, "--acme-dir", self.tempdir, "--directory-url", self.DIR_URL, #"--check-port", self.check_port, # defaults to port 80 anyway, so test that the default works ]) except Exception as e: result = e self.assertIsInstance(result, ValueError) self.assertIn("key too small", result.args[0]) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1629597348.0 acme-tiny-5.0.1/tests/utils.py0000664000175000017500000003062500000000000016410 0ustar00user1user100000000000000import os import sys import json import time from tempfile import NamedTemporaryFile, mkdtemp from subprocess import Popen try: from urllib.request import urlopen # Python 3 except ImportError: # pragma: no cover from urllib2 import urlopen # Python 2 def gen_keys(domain): """ Generate test account and domain keys """ # openssl config is system dependent openssl_cnf = None for possible_cnf in ['/etc/pki/tls/openssl.cnf', '/etc/ssl/openssl.cnf']: if os.path.exists(possible_cnf): with open(possible_cnf) as f: openssl_cnf = f.read().encode("utf8") # good account key account_key = NamedTemporaryFile() Popen(["openssl", "genrsa", "-out", account_key.name, "2048"]).wait() # weak 1024 bit key weak_key = NamedTemporaryFile() Popen(["openssl", "genrsa", "-out", weak_key.name, "1024"]).wait() # good domain key domain_key = NamedTemporaryFile() Popen(["openssl", "genrsa", "-out", domain_key.name, "2048"]).wait() # good domain csr domain_csr = NamedTemporaryFile() domain_conf = NamedTemporaryFile() domain_conf.write(openssl_cnf) domain_conf.write("\n[SAN]\nsubjectAltName=DNS:{0}\n".format(domain).encode("utf8")) domain_conf.flush() domain_conf.seek(0) Popen(["openssl", "req", "-new", "-sha256", "-key", domain_key.name, "-subj", "/", "-reqexts", "SAN", "-config", domain_conf.name, "-out", domain_csr.name]).wait() # good domain via the Common Name cn_key = NamedTemporaryFile() cn_csr = NamedTemporaryFile() Popen(["openssl", "req", "-newkey", "rsa:2048", "-nodes", "-keyout", cn_key.name, "-subj", "/CN={0}".format(domain), "-out", cn_csr.name]).wait() # invalid domain csr invalid_csr = NamedTemporaryFile() invalid_conf = NamedTemporaryFile() invalid_conf.write(openssl_cnf) invalid_conf.write(u"\n[SAN]\nsubjectAltName=DNS:\xC3\xA0\xC2\xB2\xC2\xA0_\xC3\xA0\xC2\xB2\xC2\xA0.com\n".encode("utf8")) invalid_conf.seek(0) Popen(["openssl", "req", "-new", "-sha256", "-key", domain_key.name, "-subj", "/", "-reqexts", "SAN", "-config", invalid_conf.name, "-out", invalid_csr.name]).wait() # nonexistent domain csr nonexistent_csr = NamedTemporaryFile() nonexistent_conf = NamedTemporaryFile() nonexistent_conf.write(openssl_cnf) nonexistent_conf.write("\n[SAN]\nsubjectAltName=DNS:404.gethttpsforfree.com\n".encode("utf8")) nonexistent_conf.seek(0) Popen(["openssl", "req", "-new", "-sha256", "-key", domain_key.name, "-subj", "/", "-reqexts", "SAN", "-config", nonexistent_conf.name, "-out", nonexistent_csr.name]).wait() # account-signed domain csr account_csr = NamedTemporaryFile() account_conf = NamedTemporaryFile() account_conf.write(openssl_cnf) account_conf.write("\n[SAN]\nsubjectAltName=DNS:{0}\n".format(domain).encode("utf8")) account_conf.seek(0) Popen(["openssl", "req", "-new", "-sha256", "-key", account_key.name, "-subj", "/", "-reqexts", "SAN", "-config", account_conf.name, "-out", account_csr.name]).wait() return { "account_key": account_key, "weak_key": weak_key, "cn_key": cn_key, "cn_csr": cn_csr, "domain_key": domain_key, "domain_csr": domain_csr, "invalid_csr": invalid_csr, "nonexistent_csr": nonexistent_csr, "account_csr": account_csr, } # Pebble server TLS certs # !!! DO NOT USE FOR ANYTHING EXCEPT TESTS !!! # Generated using the following commands: # openssl genrsa -out pebble_cert.key 4096 # openssl req -x509 -new -nodes -key pebble_cert.key -days 9999 -subj "/" -addext "subjectAltName=DNS:localhost" -out pebble_cert.crt PEBBLE_CERT_KEY = """ -----BEGIN RSA PRIVATE KEY----- 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 -----END RSA PRIVATE KEY----- """ PEBBLE_CERT = """ -----BEGIN CERTIFICATE----- MIIE9zCCAt+gAwIBAgIUTKfSOxaM/Gy3XrDc/3+xn4uy44YwDQYJKoZIhvcNAQEL BQAwADAeFw0yMTA4MDIyMTQ3MjZaFw00ODEyMTcyMTQ3MjZaMAAwggIiMA0GCSqG SIb3DQEBAQUAA4ICDwAwggIKAoICAQDm9avXjGUpl3gdhnZYvIhl2xMO1WcEVHt6 DermNjF9lEfPU13XtPcz3gajl6mHkbeHx/eWlA2IsIIZ6cwDXitC3BJ5SiUUgDPn zP0/H7EEzaFbHrTJx8nylEC9jmCH1uSBn9xkNVrfc8WJgBcAn41yPAxDNWLBfLqy OYrdC2CNBplbZBTz/UAdW9nJUgJSFFKWyvYt9Oq2gjI2yOZZKq5pYHZsEJxUAqtx /CHg5QPFYMdmXddELAGeu8CpVdrrxN1hwfjvO07G5mTYAf39nw2ZNtcMnrlplZVb Ud1v4fd+fhb5mDVzIA7TawR7LoppUlq7KeJmE/atryeDmFjmX3g0JTiWXplhogOe SOD4Rvttj9aJFVx6yBJN609eVtzQTsM+MTdT6XHilCFU/zJRfZVAumhV4pwyf0xs K/prRs1HXbGZBuW5HRUPeEcoC+N/3YUVWqvUqIP/Jkr1oBCPBE3ZO+uoi1mkOYlz raDBmAtFKlCGl88ZpKa0QS6EppIcnGDZbHM1Q/RC1pMuNPUWYG7BklNw33Pkir2k Q0urxc+vYvWDaSb8IrVafnzhaaay8sTXJZK4gxE9TmBDqsvvdborn493zY0Y1xQc mBqlUScddt0pyAJUjF5VgFPD2x9HsxG9VuX07x6yt5l/IWM1O0OXe3PDrJDxSAsr NL8A80WxzwIDAQABo2kwZzAdBgNVHQ4EFgQUsA4MGHUxvlXDhDUm2K7LN9u7DhUw HwYDVR0jBBgwFoAUsA4MGHUxvlXDhDUm2K7LN9u7DhUwDwYDVR0TAQH/BAUwAwEB /zAUBgNVHREEDTALgglsb2NhbGhvc3QwDQYJKoZIhvcNAQELBQADggIBAILebPEw 06Fow41ZbnSxTiuD9AaAL4PJe4l0gO33BoaES/kmoJMa9cEEkRTzScwljhC4eekc EnbqT2H1pgBwYSg9SW/fYrhGzSlKHCA62VNQ1benZSO78IY12ld+g6OaYT+QLtXf lBiRF4+L/9BOzYprfymr2HwdyaOLZc6Mf3YhGmMYqMdKriqrsqMYZmLEZ5Z2pNoF kiIFnWJeXcuSMkML/TWHMqL1IY1PN07bXeeWWsC8xD+YsLQIfdbEbf/hGSoby6nO JDDVUNHiSwEEhreXgBc/sLj/7G/BEEuU/u/fOi+NoK/gy4PHzNE7ZmmsDVyedByh 3L5bBsZGJwK+Rbz7fnthKe3ghhd+fSwRvfx07V3QBwlcD1iq/Im/UR5p3zbSR0gt zplW4F6fLAjkkGBNVNKEgRdYTF8FzHJWoHH1+kBKylb9L1p6kbUhJAbtYfkhZf2E 5QehOPt3WnVJDeDVKTyhFUWsOrOVmXuY5QV114jJaEfrBKrsJ/DTDrBiOS0jKDdI MQ2xZK0fvI15Osnr2OCggZk5kdAyaOM3ERWPVetBF9aKKFpIQMi1keOM/U5vBAJy LYEIK0jwMTf3vctsHkeWGVVMf2P498/+KHbomtUBBJU/0jp9G62xWukle5pfzfM9 F5OzP6TuNVIGpCKuPMLZTfcSCPV3ZUEizOVX -----END CERTIFICATE----- """ class PebbleServerException(Exception): pass def setup_pebble(pebble_bin_path, bad_nonces=0): """ Start a pebble server and challenge file server """ # make testing cert temp files pebble_crt = NamedTemporaryFile(delete=False) # keep until manually cleaned up in tearDown pebble_crt.write(PEBBLE_CERT.encode("utf8")) pebble_crt.flush() pebble_key = NamedTemporaryFile(delete=False) # keep until manually cleaned up in tearDown pebble_key.write(PEBBLE_CERT_KEY.encode("utf8")) pebble_key.flush() # generate the pebble config pebble_config = { "pebble": { "listenAddress": "127.0.0.1:14000", "managementListenAddress": "127.0.0.1:15000", "certificate": pebble_crt.name, "privateKey": pebble_key.name, "httpPort": 5002, "tlsPort": 5001, "ocspResponderURL": "", "externalAccountBindingRequired": False, } } pebble_conf_file = NamedTemporaryFile() pebble_conf_file.write(json.dumps(pebble_config, indent=4, sort_keys=True).encode("utf8")) pebble_conf_file.flush() # start the pebble server os.environ['PEBBLE_AUTHZREUSE'] = str(100) os.environ['PEBBLE_WFE_NONCEREJECT'] = str(bad_nonces) pebble_server_proc = Popen([pebble_bin_path, "-config", pebble_conf_file.name]) # trust the pebble server cert by default os.environ['SSL_CERT_FILE'] = pebble_config['pebble']['certificate'] # wait until the pebble server responds wait_start = time.time() MAX_WAIT = 10 # 10 seconds while (time.time() - wait_start) < MAX_WAIT: try: resp = urlopen("https://localhost:14000/dir") if resp.getcode() == 200: break # done! except IOError: pass # don't care about failed connections time.sleep(0.5) # wait a bit and try again else: # pragma: no cover pebble_server_proc.terminate() raise PebbleServerException("pebble failed to start :(") return pebble_server_proc, pebble_config class ChallengeFileServerException(Exception): pass def setup_local_fileserver(test_port, pebble_proc=None): """ Start a local challenge file server """ # set challenge file temporary directory base_tempdir = mkdtemp() acme_tempdir = os.path.join(base_tempdir, ".well-known", "acme-challenge") os.makedirs(acme_tempdir) # start a fileserver for serving up challenges local_fileserver_proc = Popen([ "python", "-m", "SimpleHTTPServer" if sys.version_info.major == 2 else "http.server", test_port, ], cwd=base_tempdir) # make sure the fileserver is running testchallenge_text = "aaa".encode("utf8") testchallenge_path = os.path.join(acme_tempdir, "a.txt") testchallenge_file = open(testchallenge_path, "wb") testchallenge_file.write(testchallenge_text) testchallenge_file.close() wait_start = time.time() MAX_WAIT = 10 # 10 seconds while (time.time() - wait_start) < MAX_WAIT: try: resp = urlopen("http://localhost:{}/.well-known/acme-challenge/a.txt".format(test_port)) if resp.getcode() == 200 and resp.read() == testchallenge_text: os.remove(testchallenge_path) break # done! except IOError: pass # don't care about failed connections time.sleep(0.5) # wait a bit and try again else: # pragma: no cover os.remove(testchallenge_path) local_fileserver_proc.terminate() if pebble_proc is not None: pebble_proc.terminate() # also shut down pebble server (if any) before raising exception raise ChallengeFileServerException("challenge file server failed to start :(") return local_fileserver_proc, base_tempdir, acme_tempdir