acme-tiny-4.1.0/0000775000175000017500000000000013510531167014460 5ustar diafygidiafygi00000000000000acme-tiny-4.1.0/.gitignore0000664000175000017500000000127612625516134016461 0ustar diafygidiafygi00000000000000# 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/ acme-tiny-4.1.0/.travis.yml0000664000175000017500000000110313510530337016562 0ustar diafygidiafygi00000000000000sudo: required language: python matrix: include: - dist: xenial python: "2.7" - dist: trusty python: "3.3" - dist: xenial python: "3.4" - dist: xenial python: "3.5" - dist: xenial python: "3.6" - dist: xenial python: "3.7" - dist: xenial python: "nightly" before_install: - sudo apt-get install fuse - sudo chmod a+r /etc/fuse.conf install: - pip install -r tests/requirements.txt script: - coverage run --source ./ --omit ./tests/server.py,./setup.py -m unittest tests after_success: - coveralls acme-tiny-4.1.0/acme_tiny.py0000775000175000017500000002637013510530337017013 0ustar diafygidiafygi00000000000000#!/usr/bin/env python # 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: 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): 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:\n\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 = "RS256" jwk = { "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("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} account, code, acct_headers = _send_signed_request(directory['newAccount'], reg_payload, "Error registering") log.info("Registered!" if code == 201 else "Already registered!") 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'] 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}/.well-known/acme-challenge/{1}".format(domain, 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 Example Crontab Renewal (once per month): 0 0 1 * * python /path/to/acme_tiny.py --account-key /path/to/account.key --csr /path/to/domain.csr --acme-dir /usr/share/nginx/html/.well-known/acme-challenge/ > /path/to/signed_chain.crt 2>> /var/log/acme_tiny.log """) ) 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") 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) sys.stdout.write(signed_crt) if __name__ == "__main__": # pragma: no cover main(sys.argv[1:]) acme-tiny-4.1.0/setup.cfg0000664000175000017500000000010013510531167016270 0ustar diafygidiafygi00000000000000[wheel] universal = True [egg_info] tag_build = tag_date = 0 acme-tiny-4.1.0/acme_tiny.egg-info/0000775000175000017500000000000013510531167020122 5ustar diafygidiafygi00000000000000acme-tiny-4.1.0/acme_tiny.egg-info/SOURCES.txt0000664000175000017500000000056413510531167022013 0ustar diafygidiafygi00000000000000.gitignore .travis.yml LICENSE README.md acme_tiny.py setup.cfg setup.py 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/monkey.py tests/requirements.txt tests/server.py tests/test_install.py tests/test_module.pyacme-tiny-4.1.0/acme_tiny.egg-info/top_level.txt0000664000175000017500000000001213510531167022645 0ustar diafygidiafygi00000000000000acme_tiny acme-tiny-4.1.0/acme_tiny.egg-info/entry_points.txt0000664000175000017500000000005613510531167023421 0ustar diafygidiafygi00000000000000[console_scripts] acme-tiny = acme_tiny:main acme-tiny-4.1.0/acme_tiny.egg-info/PKG-INFO0000664000175000017500000000163713510531167021226 0ustar diafygidiafygi00000000000000Metadata-Version: 1.1 Name: acme-tiny Version: 4.1.0 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 Description: UNKNOWN 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 acme-tiny-4.1.0/acme_tiny.egg-info/dependency_links.txt0000664000175000017500000000000113510531167024170 0ustar diafygidiafygi00000000000000 acme-tiny-4.1.0/LICENSE0000664000175000017500000000207212625516134015471 0ustar diafygidiafygi00000000000000The 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. acme-tiny-4.1.0/README.md0000664000175000017500000002040113510530337015732 0ustar diafygidiafygi00000000000000# acme-tiny [![Build Status](https://travis-ci.org/diafygi/acme-tiny.svg)](https://travis-ci.org/diafygi/acme-tiny) [![Coverage Status](https://coveralls.io/repos/diafygi/acme-tiny/badge.svg?branch=master&service=github)](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 <(python 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 "/" -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 TLSv1.1 TLSv1.2; ssl_ciphers ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA:ECDHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA; 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 bash script 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 (not a big deal, it should still work fine, but just makes 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! ## 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. acme-tiny-4.1.0/PKG-INFO0000664000175000017500000000163713510531167015564 0ustar diafygidiafygi00000000000000Metadata-Version: 1.1 Name: acme-tiny Version: 4.1.0 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 Description: UNKNOWN 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 acme-tiny-4.1.0/setup.py0000664000175000017500000000213513510530337016171 0ustar diafygidiafygi00000000000000from 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', ] ) acme-tiny-4.1.0/tests/0000775000175000017500000000000013510531167015622 5ustar diafygidiafygi00000000000000acme-tiny-4.1.0/tests/__init__.py0000664000175000017500000000011213277274362017740 0ustar diafygidiafygi00000000000000from .test_module import TestModule from .test_install import TestInstall acme-tiny-4.1.0/tests/server.py0000664000175000017500000000215213251153754017506 0ustar diafygidiafygi00000000000000import os, re, hmac from wsgiref.simple_server import make_server KEY_AUTHORIZATION = {"uri": "/not_set", "data": ""} TRAVIS_SESSION = os.getenv("TRAVIS_SESSION", "not_yet_set") def app(req, resp): if req['REQUEST_METHOD'] == "POST": if hmac.compare_digest(req['QUERY_STRING'], TRAVIS_SESSION): body_len = min(int(req['CONTENT_LENGTH']), 90) body = req['wsgi.input'].read(body_len).decode("utf8") body = re.sub(r"[^A-Za-z0-9_\-\.]", "_", body) KEY_AUTHORIZATION['uri'] = "/{0}".format(body.split(".", 1)[0]) KEY_AUTHORIZATION['data'] = body resp('201 Created', []) return ["".encode("utf8")] else: resp("403 Forbidden", []) return ["403 Forbidden".encode("utf8")] else: if hmac.compare_digest(req['PATH_INFO'], KEY_AUTHORIZATION['uri']): resp('200 OK', []) return [KEY_AUTHORIZATION['data'].encode("utf8")] else: resp("404 Not Found", []) return ["404 Not Found".encode("utf8")] make_server("localhost", 8888, app).serve_forever() acme-tiny-4.1.0/tests/test_module.py0000664000175000017500000001621413510530337020522 0ustar diafygidiafygi00000000000000import unittest, os, sys, tempfile, logging from subprocess import Popen, PIPE try: from StringIO import StringIO # Python 2 except ImportError: from io import StringIO # Python 3 import acme_tiny from .monkey import gen_keys KEYS = gen_keys() class TestModule(unittest.TestCase): "Tests for acme_tiny.get_crt()" def setUp(self): self.DIR_URL = "https://acme-staging-v02.api.letsencrypt.org/directory" self.tempdir = tempfile.mkdtemp() self.fuse_proc = Popen(["python", "tests/monkey.py", self.tempdir]) def tearDown(self): self.fuse_proc.terminate() self.fuse_proc.wait() os.rmdir(self.tempdir) def test_success_cn(self): """ Successfully issue a certificate via common name """ old_stdout = sys.stdout sys.stdout = StringIO() result = acme_tiny.main([ "--account-key", KEYS['account_key'].name, "--csr", KEYS['domain_csr'].name, "--acme-dir", self.tempdir, "--directory-url", self.DIR_URL, ]) 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("Issuer: CN=Fake LE Intermediate", out.decode("utf8")) def test_success_san(self): """ Successfully issue a certificate via subject alt name """ old_stdout = sys.stdout sys.stdout = StringIO() result = acme_tiny.main([ "--account-key", KEYS['account_key'].name, "--csr", KEYS['san_csr'].name, "--acme-dir", self.tempdir, "--directory-url", self.DIR_URL, ]) 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("Issuer: CN=Fake LE Intermediate", out.decode("utf8")) def test_success_cli(self): """ Successfully issue a certificate via command line interface """ crt, err = Popen([ "python", "acme_tiny.py", "--account-key", KEYS['account_key'].name, "--csr", KEYS['domain_csr'].name, "--acme-dir", self.tempdir, "--directory-url", self.DIR_URL, ], stdout=PIPE, stderr=PIPE).communicate() out, err = Popen(["openssl", "x509", "-text", "-noout"], stdin=PIPE, stdout=PIPE, stderr=PIPE).communicate(crt) self.assertIn("Issuer: CN=Fake LE Intermediate", 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", KEYS['domain_csr'].name, "--acme-dir", self.tempdir, "--directory-url", self.DIR_URL, ]) except Exception as e: result = e self.assertIsInstance(result, IOError) self.assertIn("Error opening 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", KEYS['account_key'].name, "--csr", "/foo/bar", "--acme-dir", self.tempdir, "--directory-url", self.DIR_URL, ]) except Exception as e: result = e self.assertIsInstance(result, IOError) self.assertIn("Error loading /foo/bar", result.args[0]) def test_weak_key(self): """ Let's Encrypt rejects weak keys """ try: result = acme_tiny.main([ "--account-key", KEYS['weak_key'].name, "--csr", KEYS['domain_csr'].name, "--acme-dir", self.tempdir, "--directory-url", self.DIR_URL, ]) except Exception as e: result = e self.assertIsInstance(result, ValueError) self.assertIn("key too small", result.args[0]) def test_invalid_domain(self): """ Let's Encrypt rejects invalid domains """ try: result = acme_tiny.main([ "--account-key", KEYS['account_key'].name, "--csr", KEYS['invalid_csr'].name, "--acme-dir", self.tempdir, "--directory-url", self.DIR_URL, ]) except Exception as e: result = e self.assertIsInstance(result, ValueError) self.assertIn("Invalid character in DNS name", result.args[0]) def test_nonexistent_domain(self): """ Should be unable verify a nonexistent domain """ try: result = acme_tiny.main([ "--account-key", KEYS['account_key'].name, "--csr", KEYS['nonexistent_csr'].name, "--acme-dir", self.tempdir, "--directory-url", self.DIR_URL, ]) 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", KEYS['account_key'].name, "--csr", KEYS['account_csr'].name, "--acme-dir", self.tempdir, "--directory-url", self.DIR_URL, ]) except Exception as e: result = e self.assertIsInstance(result, ValueError) self.assertIn("certificate public key must be different than account key", 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", KEYS['account_key'].name, "--csr", KEYS['domain_csr'].name, "--acme-dir", self.tempdir, "--directory-url", self.DIR_URL, "--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("Issuer: CN=Fake LE Intermediate", out.decode("utf8")) self.assertIn("Updated contact details:\nmailto:devteam@gethttpsforfree.com\nmailto:boss@gethttpsforfree.com", log_string.decode("utf8")) # remove logging capture acme_tiny.LOGGER.removeHandler(debug_handler) acme-tiny-4.1.0/tests/test_install.py0000664000175000017500000000116413277274362020716 0ustar diafygidiafygi00000000000000import 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"]) acme-tiny-4.1.0/tests/README.md0000664000175000017500000000430513277274362017116 0ustar diafygidiafygi00000000000000# How to test acme-tiny Testing acme-tiny requires a bit of setup since it interacts with other servers (Let's Encrypt's staging server) to test issuing fake certificates. This readme explains how to setup and test acme-tiny yourself. ## Setup instructions 1. Make a test subdomain for a server you control. Set it as an environmental variable on your local test setup. * On your local: `export TRAVIS_DOMAIN=travis-ci.gethttpsforfree.com` * Configure the webserver on `$TRAVIS_DOMAIN` for redirection of `http://$TRAVIS_DOMAIN/.well-known/acme-challenge/` to `http://localhost:8888/` 2. Generate a shared secret between your local test setup and your server. * `openssl rand -base64 32` * On your local: `export TRAVIS_SESSION=""` 3. Copy and run the test suite mini-server on your server: * `scp server.py ubuntu@travis-ci.gethttpsforfree.com` * `ssh ubuntu@travis-ci.gethttpsforfree.com` * `export TRAVIS_SESSION=""` * `sudo server.py` 4. Install the test requirements on your local (FUSE and optionally coveralls). * `sudo apt-get install fuse` * `virtualenv /tmp/venv` * `source /tmp/venv/bin/activate` * `pip install -r requirements.txt` 5. Run the test suit on your local. * `cd /path/to/acme-tiny` * `coverage run --source ./ --omit ./tests/server.py,./setup.py -m unittest tests` ## Why use FUSE? Acme-tiny writes the challenge files for certificate issuance. In order to do full integration tests, we actually need to serve correct challenge files to the Let's Encrypt staging server on a real domain that they can verify. However, Travis-CI doesn't have domains associated with their test VMs, so we need to send the files to the remote server that does have a real domain. The test suite uses FUSE to do this. It creates a FUSE folder that simulates being a real folder to acme-tiny. When acme-tiny writes the challenge files in the mock folder, FUSE POSTs those files to the real server (which is running the included server.py), and the server starts serving them. That way, both acme-tiny and Let's Encrypt staging can verify and issue the test certificate. This technique allows for high test coverage on automated test runners (e.g. Travis-CI). acme-tiny-4.1.0/tests/requirements.txt0000664000175000017500000000003212631007545021102 0ustar diafygidiafygi00000000000000coveralls fusepy argparse acme-tiny-4.1.0/tests/monkey.py0000664000175000017500000000637313277274362017522 0ustar diafygidiafygi00000000000000import os, sys from tempfile import NamedTemporaryFile from subprocess import Popen from fuse import FUSE, Operations, LoggingMixIn try: from urllib.request import urlopen # Python 3 except ImportError: from urllib2 import urlopen # Python 2 # domain with server.py running on it for testing DOMAIN = os.getenv("TRAVIS_DOMAIN", "travis-ci.gethttpsforfree.com") # generate account and domain keys def gen_keys(): # 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() domain_csr = NamedTemporaryFile() Popen(["openssl", "req", "-newkey", "rsa:2048", "-nodes", "-keyout", domain_key.name, "-subj", "/CN={0}".format(DOMAIN), "-out", domain_csr.name]).wait() # subject alt-name domain san_csr = NamedTemporaryFile() san_conf = NamedTemporaryFile() for openssl_cnf in ['/etc/pki/tls/openssl.cnf', '/etc/ssl/openssl.cnf']: if os.path.exists(openssl_cnf): break san_conf.write(open(openssl_cnf).read().encode("utf8")) san_conf.write("\n[SAN]\nsubjectAltName=DNS:{0}\n".format(DOMAIN).encode("utf8")) san_conf.seek(0) Popen(["openssl", "req", "-new", "-sha256", "-key", domain_key.name, "-subj", "/", "-reqexts", "SAN", "-config", san_conf.name, "-out", san_csr.name]).wait() # invalid domain csr invalid_csr = NamedTemporaryFile() Popen(["openssl", "req", "-new", "-sha256", "-key", domain_key.name, "-subj", "/CN=\xC3\xA0\xC2\xB2\xC2\xA0_\xC3\xA0\xC2\xB2\xC2\xA0.com", "-out", invalid_csr.name]).wait() # nonexistent domain csr nonexistent_csr = NamedTemporaryFile() Popen(["openssl", "req", "-new", "-sha256", "-key", domain_key.name, "-subj", "/CN=404.gethttpsforfree.com", "-out", nonexistent_csr.name]).wait() # account-signed domain csr account_csr = NamedTemporaryFile() Popen(["openssl", "req", "-new", "-sha256", "-key", account_key.name, "-subj", "/CN={0}".format(DOMAIN), "-out", account_csr.name]).wait() return { "account_key": account_key, "weak_key": weak_key, "domain_key": domain_key, "domain_csr": domain_csr, "san_csr": san_csr, "invalid_csr": invalid_csr, "nonexistent_csr": nonexistent_csr, "account_csr": account_csr, } # fake a folder structure to catch the key authorization file FS = {} class Passthrough(LoggingMixIn, Operations): # pragma: no cover def getattr(self, path, fh=None): f = FS.get(path, None) if f is None: return super(Passthrough, self).getattr(path, fh=fh) return f def write(self, path, buf, offset, fh): urlopen("http://{0}/.well-known/acme-challenge/?{1}".format(DOMAIN, os.getenv("TRAVIS_SESSION", "not_set")), buf) return len(buf) def create(self, path, mode, fi=None): FS[path] = {"st_mode": 33204} return 0 def unlink(self, path): del(FS[path]) return 0 if __name__ == "__main__": # pragma: no cover FUSE(Passthrough(), sys.argv[1], nothreads=True, foreground=True)