├── tests ├── __init__.py ├── requirements.txt ├── server.py ├── README.md ├── monkey.py └── test_module.py ├── .travis.yml ├── .gitignore ├── LICENSE ├── README.md └── acme_tiny.py /tests/__init__.py: -------------------------------------------------------------------------------- 1 | from .test_module import TestModule 2 | -------------------------------------------------------------------------------- /tests/requirements.txt: -------------------------------------------------------------------------------- 1 | coveralls 2 | fusepy 3 | argparse 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | dist: trusty 3 | language: python 4 | python: 5 | - "2.7" 6 | - "3.3" 7 | - "3.4" 8 | - "3.5" 9 | - "nightly" 10 | before_install: 11 | - sudo apt-get install fuse 12 | - sudo chmod a+r /etc/fuse.conf 13 | install: 14 | - pip install -r tests/requirements.txt 15 | script: 16 | - coverage run --source ./ --omit ./tests/server.py -m unittest tests 17 | after_success: 18 | - coveralls 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .coverage.* 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | *,cover 45 | 46 | # Translations 47 | *.mo 48 | *.pot 49 | 50 | # Django stuff: 51 | *.log 52 | 53 | # Sphinx documentation 54 | docs/_build/ 55 | 56 | # PyBuilder 57 | target/ 58 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Daniel Roesler 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /tests/server.py: -------------------------------------------------------------------------------- 1 | import os, re, hmac 2 | from wsgiref.simple_server import make_server 3 | 4 | KEY_AUTHORIZATION = {"uri": "/not_set", "data": ""} 5 | TRAVIS_SESSION = os.getenv("TRAVIS_SESSION", "not_yet_set") 6 | 7 | def app(req, resp): 8 | if req['REQUEST_METHOD'] == "POST": 9 | if hmac.compare_digest(req['QUERY_STRING'], TRAVIS_SESSION): 10 | body_len = min(int(req['CONTENT_LENGTH']), 90) 11 | body = req['wsgi.input'].read(body_len).decode("utf8") 12 | body = re.sub(r"[^A-Za-z0-9_\-\.]", "_", body) 13 | KEY_AUTHORIZATION['uri'] = "/{0}".format(body.split(".", 1)[0]) 14 | KEY_AUTHORIZATION['body'] = body 15 | resp('201 Created', []) 16 | return ["".encode("utf8")] 17 | else: 18 | resp("403 Forbidden", []) 19 | return ["403 Forbidden".encode("utf8")] 20 | else: 21 | if hmac.compare_digest(req['PATH_INFO'], KEY_AUTHORIZATION['uri']): 22 | resp('200 OK', []) 23 | return [KEY_AUTHORIZATION['data'].encode("utf8")] 24 | else: 25 | resp("404 Not Found", []) 26 | return ["404 Not Found".encode("utf8")] 27 | 28 | make_server("localhost", 8888, app).serve_forever() 29 | -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | # How to test acme-tiny 2 | 3 | Testing acme-tiny requires a bit of setup since it interacts with other servers 4 | (Let's Encrypt's staging server) to test issuing fake certificates. This readme 5 | explains how to setup and test acme-tiny yourself. 6 | 7 | ## Setup instructions 8 | 9 | 1. Make a test subdomain for a server you control. Set it as an environmental 10 | variable on your local test setup. 11 | * On your local: `export TRAVIS_DOMAIN=travis-ci.gethttpsforfree.com` 12 | 2. Generate a shared secret between your local test setup and your server. 13 | * `openssl rand -base64 32` 14 | * On your local: `export TRAVIS_SESSION=""` 15 | 3. Copy and run the test suite mini-server on your server: 16 | * `scp server.py ubuntu@travis-ci.gethttpsforfree.com` 17 | * `ssh ubuntu@travis-ci.gethttpsforfree.com` 18 | * `export TRAVIS_SESSION=""` 19 | * `sudo server.py` 20 | 4. Install the test requirements on your local (FUSE and optionally coveralls). 21 | * `sudo apt-get install fuse` 22 | * `virtualenv /tmp/venv` 23 | * `source /tmp/venv/bin/activate` 24 | * `pip install -r requirements.txt` 25 | 5. Run the test suit on your local. 26 | * `cd /path/to/acme-tiny` 27 | * `coverage run --source ./ --omit ./tests/server.py -m unittest tests` 28 | 29 | ## Why use FUSE? 30 | 31 | Acme-tiny writes the challenge files for certificate issuance. In order to do 32 | full integration tests, we actually need to serve correct challenge files to 33 | the Let's Encrypt staging server on a real domain that they can verify. However, 34 | Travis-CI doesn't have domains associated with their test VMs, so we need to 35 | send the files to the remote server that does have a real domain. 36 | 37 | The test suite uses FUSE to do this. It creates a FUSE folder that simulates 38 | being a real folder to acme-tiny. When acme-tiny writes the challenge files 39 | in the mock folder, FUSE POSTs those files to the real server (which is running 40 | the included server.py), and the server starts serving them. That way, both 41 | acme-tiny and Let's Encrypt staging can verify and issue the test certificate. 42 | This technique allows for high test coverage on automated test runners (e.g. 43 | Travis-CI). 44 | 45 | -------------------------------------------------------------------------------- /tests/monkey.py: -------------------------------------------------------------------------------- 1 | import os, sys 2 | from tempfile import NamedTemporaryFile 3 | from subprocess import Popen 4 | from fuse import FUSE, Operations, LoggingMixIn 5 | try: 6 | from urllib.request import urlopen # Python 3 7 | except ImportError: 8 | from urllib2 import urlopen # Python 2 9 | 10 | # domain with server.py running on it for testing 11 | DOMAIN = os.getenv("TRAVIS_DOMAIN", "travis-ci.gethttpsforfree.com") 12 | 13 | # generate account and domain keys 14 | def gen_keys(): 15 | # good account key 16 | account_key = NamedTemporaryFile() 17 | Popen(["openssl", "genrsa", "-out", account_key.name, "2048"]).wait() 18 | 19 | # weak 1024 bit key 20 | weak_key = NamedTemporaryFile() 21 | Popen(["openssl", "genrsa", "-out", weak_key.name, "1024"]).wait() 22 | 23 | # good domain key 24 | domain_key = NamedTemporaryFile() 25 | domain_csr = NamedTemporaryFile() 26 | Popen(["openssl", "req", "-newkey", "rsa:2048", "-nodes", "-keyout", domain_key.name, 27 | "-subj", "/CN={0}".format(DOMAIN), "-out", domain_csr.name]).wait() 28 | 29 | # subject alt-name domain 30 | san_csr = NamedTemporaryFile() 31 | san_conf = NamedTemporaryFile() 32 | san_conf.write(open("/etc/ssl/openssl.cnf").read().encode("utf8")) 33 | san_conf.write("\n[SAN]\nsubjectAltName=DNS:{0}\n".format(DOMAIN).encode("utf8")) 34 | san_conf.seek(0) 35 | Popen(["openssl", "req", "-new", "-sha256", "-key", domain_key.name, 36 | "-subj", "/", "-reqexts", "SAN", "-config", san_conf.name, 37 | "-out", san_csr.name]).wait() 38 | 39 | # invalid domain csr 40 | invalid_csr = NamedTemporaryFile() 41 | Popen(["openssl", "req", "-new", "-sha256", "-key", domain_key.name, 42 | "-subj", "/CN=\xC3\xA0\xC2\xB2\xC2\xA0_\xC3\xA0\xC2\xB2\xC2\xA0.com", "-out", invalid_csr.name]).wait() 43 | 44 | # nonexistent domain csr 45 | nonexistent_csr = NamedTemporaryFile() 46 | Popen(["openssl", "req", "-new", "-sha256", "-key", domain_key.name, 47 | "-subj", "/CN=404.gethttpsforfree.com", "-out", nonexistent_csr.name]).wait() 48 | 49 | # account-signed domain csr 50 | account_csr = NamedTemporaryFile() 51 | Popen(["openssl", "req", "-new", "-sha256", "-key", account_key.name, 52 | "-subj", "/CN={0}".format(DOMAIN), "-out", account_csr.name]).wait() 53 | 54 | return { 55 | "account_key": account_key, 56 | "weak_key": weak_key, 57 | "domain_key": domain_key, 58 | "domain_csr": domain_csr, 59 | "san_csr": san_csr, 60 | "invalid_csr": invalid_csr, 61 | "nonexistent_csr": nonexistent_csr, 62 | "account_csr": account_csr, 63 | } 64 | 65 | # fake a folder structure to catch the key authorization file 66 | FS = {} 67 | class Passthrough(LoggingMixIn, Operations): # pragma: no cover 68 | def getattr(self, path, fh=None): 69 | f = FS.get(path, None) 70 | if f is None: 71 | return super(Passthrough, self).getattr(path, fh=fh) 72 | return f 73 | 74 | def write(self, path, buf, offset, fh): 75 | urlopen("http://{0}/.well-known/acme-challenge/?{1}".format(DOMAIN, 76 | os.getenv("TRAVIS_SESSION", "not_set")), buf) 77 | return len(buf) 78 | 79 | def create(self, path, mode, fi=None): 80 | FS[path] = {"st_mode": 33204} 81 | return 0 82 | 83 | def unlink(self, path): 84 | del(FS[path]) 85 | return 0 86 | 87 | if __name__ == "__main__": # pragma: no cover 88 | FUSE(Passthrough(), sys.argv[1], nothreads=True, foreground=True) 89 | -------------------------------------------------------------------------------- /tests/test_module.py: -------------------------------------------------------------------------------- 1 | import unittest, os, sys, tempfile 2 | from subprocess import Popen, PIPE 3 | try: 4 | from StringIO import StringIO # Python 2 5 | except ImportError: 6 | from io import StringIO # Python 3 7 | 8 | import acme_tiny 9 | from .monkey import gen_keys 10 | 11 | KEYS = gen_keys() 12 | 13 | class TestModule(unittest.TestCase): 14 | "Tests for acme_tiny.get_crt()" 15 | 16 | def setUp(self): 17 | self.CA = "https://acme-staging.api.letsencrypt.org" 18 | self.tempdir = tempfile.mkdtemp() 19 | self.fuse_proc = Popen(["python", "tests/monkey.py", self.tempdir]) 20 | 21 | def tearDown(self): 22 | self.fuse_proc.terminate() 23 | self.fuse_proc.wait() 24 | os.rmdir(self.tempdir) 25 | 26 | def test_success_cn(self): 27 | """ Successfully issue a certificate via common name """ 28 | old_stdout = sys.stdout 29 | sys.stdout = StringIO() 30 | result = acme_tiny.main([ 31 | "--account-key", KEYS['account_key'].name, 32 | "--csr", KEYS['domain_csr'].name, 33 | "--acme-dir", self.tempdir, 34 | "--ca", self.CA, 35 | ]) 36 | sys.stdout.seek(0) 37 | crt = sys.stdout.read().encode("utf8") 38 | sys.stdout = old_stdout 39 | out, err = Popen(["openssl", "x509", "-text", "-noout"], stdin=PIPE, 40 | stdout=PIPE, stderr=PIPE).communicate(crt) 41 | self.assertIn("Issuer: CN=happy hacker fake CA", out.decode("utf8")) 42 | 43 | def test_success_san(self): 44 | """ Successfully issue a certificate via subject alt name """ 45 | old_stdout = sys.stdout 46 | sys.stdout = StringIO() 47 | result = acme_tiny.main([ 48 | "--account-key", KEYS['account_key'].name, 49 | "--csr", KEYS['san_csr'].name, 50 | "--acme-dir", self.tempdir, 51 | "--ca", self.CA, 52 | ]) 53 | sys.stdout.seek(0) 54 | crt = sys.stdout.read().encode("utf8") 55 | sys.stdout = old_stdout 56 | out, err = Popen(["openssl", "x509", "-text", "-noout"], stdin=PIPE, 57 | stdout=PIPE, stderr=PIPE).communicate(crt) 58 | self.assertIn("Issuer: CN=happy hacker fake CA", out.decode("utf8")) 59 | 60 | def test_success_cli(self): 61 | """ Successfully issue a certificate via command line interface """ 62 | crt, err = Popen([ 63 | "python", "acme_tiny.py", 64 | "--account-key", KEYS['account_key'].name, 65 | "--csr", KEYS['domain_csr'].name, 66 | "--acme-dir", self.tempdir, 67 | "--ca", self.CA, 68 | ], stdout=PIPE, stderr=PIPE).communicate() 69 | out, err = Popen(["openssl", "x509", "-text", "-noout"], stdin=PIPE, 70 | stdout=PIPE, stderr=PIPE).communicate(crt) 71 | self.assertIn("Issuer: CN=happy hacker fake CA", out.decode("utf8")) 72 | 73 | def test_missing_account_key(self): 74 | """ OpenSSL throws an error when the account key is missing """ 75 | try: 76 | result = acme_tiny.main([ 77 | "--account-key", "/foo/bar", 78 | "--csr", KEYS['domain_csr'].name, 79 | "--acme-dir", self.tempdir, 80 | "--ca", self.CA, 81 | ]) 82 | except Exception as e: 83 | result = e 84 | self.assertIsInstance(result, IOError) 85 | self.assertIn("Error opening Private Key", result.args[0]) 86 | 87 | def test_missing_csr(self): 88 | """ OpenSSL throws an error when the CSR is missing """ 89 | try: 90 | result = acme_tiny.main([ 91 | "--account-key", KEYS['account_key'].name, 92 | "--csr", "/foo/bar", 93 | "--acme-dir", self.tempdir, 94 | "--ca", self.CA, 95 | ]) 96 | except Exception as e: 97 | result = e 98 | self.assertIsInstance(result, IOError) 99 | self.assertIn("Error loading /foo/bar", result.args[0]) 100 | 101 | def test_weak_key(self): 102 | """ Let's Encrypt rejects weak keys """ 103 | try: 104 | result = acme_tiny.main([ 105 | "--account-key", KEYS['weak_key'].name, 106 | "--csr", KEYS['domain_csr'].name, 107 | "--acme-dir", self.tempdir, 108 | "--ca", self.CA, 109 | ]) 110 | except Exception as e: 111 | result = e 112 | self.assertIsInstance(result, ValueError) 113 | self.assertIn("Key too small", result.args[0]) 114 | 115 | def test_invalid_domain(self): 116 | """ Let's Encrypt rejects invalid domains """ 117 | try: 118 | result = acme_tiny.main([ 119 | "--account-key", KEYS['account_key'].name, 120 | "--csr", KEYS['invalid_csr'].name, 121 | "--acme-dir", self.tempdir, 122 | "--ca", self.CA, 123 | ]) 124 | except Exception as e: 125 | result = e 126 | self.assertIsInstance(result, ValueError) 127 | self.assertIn("Invalid character in DNS name", result.args[0]) 128 | 129 | def test_nonexistant_domain(self): 130 | """ Should be unable verify a nonexistent domain """ 131 | try: 132 | result = acme_tiny.main([ 133 | "--account-key", KEYS['account_key'].name, 134 | "--csr", KEYS['nonexistent_csr'].name, 135 | "--acme-dir", self.tempdir, 136 | "--ca", self.CA, 137 | ]) 138 | except Exception as e: 139 | result = e 140 | self.assertIsInstance(result, ValueError) 141 | self.assertIn("but couldn't download", result.args[0]) 142 | 143 | def test_account_key_domain(self): 144 | """ Can't use the account key for the CSR """ 145 | try: 146 | result = acme_tiny.main([ 147 | "--account-key", KEYS['account_key'].name, 148 | "--csr", KEYS['account_csr'].name, 149 | "--acme-dir", self.tempdir, 150 | "--ca", self.CA, 151 | ]) 152 | except Exception as e: 153 | result = e 154 | self.assertIsInstance(result, ValueError) 155 | self.assertIn("Certificate public key must be different than account key", result.args[0]) 156 | 157 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # acme-tiny 2 | 3 | [![Build Status](https://travis-ci.org/diafygi/acme-tiny.svg)](https://travis-ci.org/diafygi/acme-tiny) 4 | [![Coverage Status](https://coveralls.io/repos/diafygi/acme-tiny/badge.svg?branch=master&service=github)](https://coveralls.io/github/diafygi/acme-tiny?branch=master) 5 | 6 | This is a tiny, auditable script that you can throw on your server to issue 7 | and renew [Let's Encrypt](https://letsencrypt.org/) certificates. Since it has 8 | to be run on your server and have access to your private Let's Encrypt account 9 | key, I tried to make it as tiny as possible (currently less than 200 lines). 10 | The only prerequisites are python and openssl. 11 | 12 | **PLEASE READ THE SOURCE CODE! YOU MUST TRUST IT WITH YOUR PRIVATE KEYS!** 13 | 14 | ##Donate 15 | 16 | If this script is useful to you, please donate to the EFF. I don't work there, 17 | but they do fantastic work. 18 | 19 | [https://eff.org/donate/](https://eff.org/donate/) 20 | 21 | ## How to use this script 22 | 23 | If you already have a Let's Encrypt issued certificate and just want to renew, 24 | you should only have to do Steps 3 and 6. 25 | 26 | ### Step 1: Create a Let's Encrypt account private key (if you haven't already) 27 | 28 | You must have a public key registered with Let's Encrypt and sign your requests 29 | with the corresponding private key. If you don't understand what I just said, 30 | this script likely isn't for you! Please use the official Let's Encrypt 31 | [client](https://github.com/letsencrypt/letsencrypt). 32 | To accomplish this you need to initially create a key, that can be used by 33 | acme-tiny, to register a account for you and sign all following requests. 34 | 35 | ``` 36 | openssl genrsa 4096 > account.key 37 | ``` 38 | 39 | #### Use existing Let's Encrypt key 40 | 41 | Alternatively you can convert your key, previously generated by the original 42 | Let's Encrypt client. 43 | 44 | The private account key from the Let's Encrypt client is saved in the 45 | [JWK](https://tools.ietf.org/html/rfc7517) format. `acme-tiny` is using the PEM 46 | key format. To convert the key, you can use the tool 47 | [conversion script](https://gist.github.com/JonLundy/f25c99ee0770e19dc595) by JonLundy: 48 | 49 | ```sh 50 | # Download the script 51 | wget -O - "https://gist.githubusercontent.com/JonLundy/f25c99ee0770e19dc595/raw/6035c1c8938fae85810de6aad1ecf6e2db663e26/conv.py" > conv.py 52 | 53 | # Copy your private key to your working directory 54 | cp /etc/letsencrypt/accounts/acme-v01.api.letsencrypt.org/directory//private_key.json private_key.json 55 | 56 | # Create a DER encoded private key 57 | openssl asn1parse -noout -out private_key.der -genconf <(python conv.py private_key.json) 58 | 59 | # Convert to PEM 60 | openssl rsa -in private_key.der -inform der > account.key 61 | ``` 62 | 63 | ### Step 2: Create a certificate signing request (CSR) for your domains. 64 | 65 | The ACME protocol (what Let's Encrypt uses) requires a CSR file to be submitted 66 | to it, even for renewals. You can use the same CSR for multiple renewals. NOTE: 67 | you can't use your account private key as your domain private key! 68 | 69 | ``` 70 | #generate a domain private key (if you haven't already) 71 | openssl genrsa 4096 > domain.key 72 | ``` 73 | 74 | ``` 75 | #for a single domain 76 | openssl req -new -sha256 -key domain.key -subj "/CN=yoursite.com" > domain.csr 77 | 78 | #for multiple domains (use this one if you want both www.yoursite.com and yoursite.com) 79 | 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 80 | ``` 81 | 82 | ### Step 3: Make your website host challenge files 83 | 84 | You must prove you own the domains you want a certificate for, so Let's Encrypt 85 | requires you host some files on them. This script will generate and write those 86 | files in the folder you specify, so all you need to do is make sure that this 87 | folder is served under the ".well-known/acme-challenge/" url path. NOTE: This 88 | must be on port 80 (not port 443). 89 | 90 | ``` 91 | #make some challenge folder (modify to suit your needs) 92 | mkdir -p /var/www/challenges/ 93 | ``` 94 | 95 | ```nginx 96 | #example for nginx 97 | server { 98 | listen 80; 99 | server_name yoursite.com www.yoursite.com; 100 | 101 | location /.well-known/acme-challenge/ { 102 | alias /var/www/challenges/; 103 | try_files $uri =404; 104 | } 105 | 106 | ...the rest of your config 107 | } 108 | ``` 109 | 110 | ### Step 4: Get a signed certificate! 111 | 112 | Now that you have setup your server and generated all the needed files, run this 113 | script on your server with the permissions needed to write to the above folder 114 | and read your private account key and CSR. 115 | 116 | ``` 117 | #run the script on your server 118 | python acme_tiny.py --account-key ./account.key --csr ./domain.csr --acme-dir /var/www/challenges/ > ./signed.crt 119 | ``` 120 | 121 | ### Step 5: Install the certificate 122 | 123 | The signed https certificate that is output by this script can be used along 124 | with your private key to run an https server. You need to include them in the 125 | https settings in your web server's configuration. Here's an example on how to 126 | configure an nginx server: 127 | 128 | ``` 129 | #NOTE: For nginx, you need to append the Let's Encrypt intermediate cert to your cert 130 | wget -O - https://letsencrypt.org/certs/lets-encrypt-x1-cross-signed.pem > intermediate.pem 131 | cat signed.crt intermediate.pem > chained.pem 132 | ``` 133 | 134 | ```nginx 135 | server { 136 | listen 443; 137 | server_name yoursite.com, www.yoursite.com; 138 | 139 | ssl on; 140 | ssl_certificate /path/to/chained.pem; 141 | ssl_certificate_key /path/to/domain.key; 142 | ssl_session_timeout 5m; 143 | ssl_protocols TLSv1 TLSv1.1 TLSv1.2; 144 | 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; 145 | ssl_session_cache shared:SSL:50m; 146 | ssl_dhparam /path/to/server.dhparam; 147 | ssl_prefer_server_ciphers on; 148 | 149 | ...the rest of your config 150 | } 151 | 152 | server { 153 | listen 80; 154 | server_name yoursite.com, www.yoursite.com; 155 | 156 | location /.well-known/acme-challenge/ { 157 | alias /var/www/challenges/; 158 | try_files $uri =404; 159 | } 160 | 161 | ...the rest of your config 162 | } 163 | ``` 164 | 165 | ### Step 6: Setup an auto-renew cronjob 166 | 167 | Congrats! Your website is now using https! Unfortunately, Let's Encrypt 168 | certificates only last for 90 days, so you need to renew them often. No worries! 169 | It's automated! Just make a bash script and add it to your crontab (see below 170 | for example script). 171 | 172 | Example of a `renew_cert.sh`: 173 | ```sh 174 | #!/usr/bin/sh 175 | python /path/to/acme_tiny.py --account-key /path/to/account.key --csr /path/to/domain.csr --acme-dir /var/www/challenges/ > /tmp/signed.crt || exit 176 | wget -O - https://letsencrypt.org/certs/lets-encrypt-x1-cross-signed.pem > intermediate.pem 177 | cat /tmp/signed.crt intermediate.pem > /path/to/chained.pem 178 | service nginx reload 179 | ``` 180 | 181 | ``` 182 | #example line in your crontab (runs once per month) 183 | 0 0 1 * * /path/to/renew_cert.sh 2>> /var/log/acme_tiny.log 184 | ``` 185 | 186 | ## Permissions 187 | 188 | The biggest problem you'll likely come across while setting up and running this 189 | script is permissions. You want to limit access to your account private key and 190 | challenge web folder as much as possible. I'd recommend creating a user 191 | specifically for handling this script, the account private key, and the 192 | challenge folder. Then add the ability for that user to write to your installed 193 | certificate file (e.g. `/path/to/chained.pem`) and reload your webserver. That 194 | way, the cron script will do its thing, overwrite your old certificate, and 195 | reload your webserver without having permission to do anything else. 196 | 197 | **BE SURE TO:** 198 | * Backup your account private key (e.g. `account.key`) 199 | * Don't allow this script to be able to read your domain private key! 200 | * Don't allow this script to be run as root! 201 | 202 | ## Feedback/Contributing 203 | 204 | This project has a very, very limited scope and codebase. I'm happy to receive 205 | bug reports and pull requests, but please don't add any new features. This 206 | script must stay under 200 lines of code to ensure it can be easily audited by 207 | anyone who wants to run it. 208 | 209 | If you want to add features for your own setup to make things easier for you, 210 | please do! It's open source, so feel free to fork it and modify as necessary. 211 | -------------------------------------------------------------------------------- /acme_tiny.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import argparse, subprocess, json, os, sys, base64, binascii, time, hashlib, re, copy, textwrap, logging 3 | try: 4 | from urllib.request import urlopen # Python 3 5 | except ImportError: 6 | from urllib2 import urlopen # Python 2 7 | 8 | #DEFAULT_CA = "https://acme-staging.api.letsencrypt.org" 9 | DEFAULT_CA = "https://acme-v01.api.letsencrypt.org" 10 | 11 | LOGGER = logging.getLogger(__name__) 12 | LOGGER.addHandler(logging.StreamHandler()) 13 | LOGGER.setLevel(logging.INFO) 14 | 15 | def get_crt(account_key, csr, acme_dir, log=LOGGER, CA=DEFAULT_CA): 16 | # helper function base64 encode for jose spec 17 | def _b64(b): 18 | return base64.urlsafe_b64encode(b).decode('utf8').replace("=", "") 19 | 20 | # parse account key to get public key 21 | log.info("Parsing account key...") 22 | proc = subprocess.Popen(["openssl", "rsa", "-in", account_key, "-noout", "-text"], 23 | stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 24 | out, err = proc.communicate() 25 | if proc.returncode != 0: 26 | raise IOError("OpenSSL Error: {0}".format(err)) 27 | pub_hex, pub_exp = re.search( 28 | r"modulus:\n\s+00:([a-f0-9\:\s]+?)\npublicExponent: ([0-9]+)", 29 | out.decode('utf8'), re.MULTILINE|re.DOTALL).groups() 30 | pub_exp = "{0:x}".format(int(pub_exp)) 31 | pub_exp = "0{0}".format(pub_exp) if len(pub_exp) % 2 else pub_exp 32 | header = { 33 | "alg": "RS256", 34 | "jwk": { 35 | "e": _b64(binascii.unhexlify(pub_exp.encode("utf-8"))), 36 | "kty": "RSA", 37 | "n": _b64(binascii.unhexlify(re.sub(r"(\s|:)", "", pub_hex).encode("utf-8"))), 38 | }, 39 | } 40 | accountkey_json = json.dumps(header['jwk'], sort_keys=True, separators=(',', ':')) 41 | thumbprint = _b64(hashlib.sha256(accountkey_json.encode('utf8')).digest()) 42 | 43 | # helper function make signed requests 44 | def _send_signed_request(url, payload): 45 | payload64 = _b64(json.dumps(payload).encode('utf8')) 46 | protected = copy.deepcopy(header) 47 | protected["nonce"] = urlopen(CA + "/directory").headers['Replay-Nonce'] 48 | protected64 = _b64(json.dumps(protected).encode('utf8')) 49 | proc = subprocess.Popen(["openssl", "dgst", "-sha256", "-sign", account_key], 50 | stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 51 | out, err = proc.communicate("{0}.{1}".format(protected64, payload64).encode('utf8')) 52 | if proc.returncode != 0: 53 | raise IOError("OpenSSL Error: {0}".format(err)) 54 | data = json.dumps({ 55 | "header": header, "protected": protected64, 56 | "payload": payload64, "signature": _b64(out), 57 | }) 58 | try: 59 | resp = urlopen(url, data.encode('utf8')) 60 | return resp.getcode(), resp.read() 61 | except IOError as e: 62 | return getattr(e, "code", None), getattr(e, "read", e.__str__)() 63 | 64 | # find domains 65 | log.info("Parsing CSR...") 66 | proc = subprocess.Popen(["openssl", "req", "-in", csr, "-noout", "-text"], 67 | stdout=subprocess.PIPE, stderr=subprocess.PIPE) 68 | out, err = proc.communicate() 69 | if proc.returncode != 0: 70 | raise IOError("Error loading {0}: {1}".format(csr, err)) 71 | domains = set([]) 72 | common_name = re.search(r"Subject:.*? CN=([^\s,;/]+)", out.decode('utf8')) 73 | if common_name is not None: 74 | domains.add(common_name.group(1)) 75 | subject_alt_names = re.search(r"X509v3 Subject Alternative Name: \n +([^\n]+)\n", out.decode('utf8'), re.MULTILINE|re.DOTALL) 76 | if subject_alt_names is not None: 77 | for san in subject_alt_names.group(1).split(", "): 78 | if san.startswith("DNS:"): 79 | domains.add(san[4:]) 80 | 81 | # get the certificate domains and expiration 82 | log.info("Registering account...") 83 | code, result = _send_signed_request(CA + "/acme/new-reg", { 84 | "resource": "new-reg", 85 | "agreement": "https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf", 86 | }) 87 | if code == 201: 88 | log.info("Registered!") 89 | elif code == 409: 90 | log.info("Already registered!") 91 | else: 92 | raise ValueError("Error registering: {0} {1}".format(code, result)) 93 | 94 | # verify each domain 95 | for domain in domains: 96 | log.info("Verifying {0}...".format(domain)) 97 | 98 | # get new challenge 99 | code, result = _send_signed_request(CA + "/acme/new-authz", { 100 | "resource": "new-authz", 101 | "identifier": {"type": "dns", "value": domain}, 102 | }) 103 | if code != 201: 104 | raise ValueError("Error requesting challenges: {0} {1}".format(code, result)) 105 | 106 | # make the challenge file 107 | challenge = [c for c in json.loads(result.decode('utf8'))['challenges'] if c['type'] == "http-01"][0] 108 | token = re.sub(r"[^A-Za-z0-9_\-]", "_", challenge['token']) 109 | keyauthorization = "{0}.{1}".format(token, thumbprint) 110 | wellknown_path = os.path.join(acme_dir, token) 111 | with open(wellknown_path, "w") as wellknown_file: 112 | wellknown_file.write(keyauthorization) 113 | 114 | # check that the file is in place 115 | wellknown_url = "http://{0}/.well-known/acme-challenge/{1}".format(domain, token) 116 | try: 117 | resp = urlopen(wellknown_url) 118 | resp_data = resp.read().decode('utf8').strip() 119 | assert resp_data == keyauthorization 120 | except (IOError, AssertionError): 121 | os.remove(wellknown_path) 122 | raise ValueError("Wrote file to {0}, but couldn't download {1}".format( 123 | wellknown_path, wellknown_url)) 124 | 125 | # notify challenge are met 126 | code, result = _send_signed_request(challenge['uri'], { 127 | "resource": "challenge", 128 | "keyAuthorization": keyauthorization, 129 | }) 130 | if code != 202: 131 | raise ValueError("Error triggering challenge: {0} {1}".format(code, result)) 132 | 133 | # wait for challenge to be verified 134 | while True: 135 | try: 136 | resp = urlopen(challenge['uri']) 137 | challenge_status = json.loads(resp.read().decode('utf8')) 138 | except IOError as e: 139 | raise ValueError("Error checking challenge: {0} {1}".format( 140 | e.code, json.loads(e.read().decode('utf8')))) 141 | if challenge_status['status'] == "pending": 142 | time.sleep(2) 143 | elif challenge_status['status'] == "valid": 144 | log.info("{0} verified!".format(domain)) 145 | os.remove(wellknown_path) 146 | break 147 | else: 148 | raise ValueError("{0} challenge did not pass: {1}".format( 149 | domain, challenge_status)) 150 | 151 | # get the new certificate 152 | log.info("Signing certificate...") 153 | proc = subprocess.Popen(["openssl", "req", "-in", csr, "-outform", "DER"], 154 | stdout=subprocess.PIPE, stderr=subprocess.PIPE) 155 | csr_der, err = proc.communicate() 156 | code, result = _send_signed_request(CA + "/acme/new-cert", { 157 | "resource": "new-cert", 158 | "csr": _b64(csr_der), 159 | }) 160 | if code != 201: 161 | raise ValueError("Error signing certificate: {0} {1}".format(code, result)) 162 | 163 | # return signed certificate! 164 | log.info("Certificate signed!") 165 | return """-----BEGIN CERTIFICATE-----\n{0}\n-----END CERTIFICATE-----\n""".format( 166 | "\n".join(textwrap.wrap(base64.b64encode(result).decode('utf8'), 64))) 167 | 168 | def main(argv): 169 | parser = argparse.ArgumentParser( 170 | formatter_class=argparse.RawDescriptionHelpFormatter, 171 | description=textwrap.dedent("""\ 172 | This script automates the process of getting a signed TLS certificate from 173 | Let's Encrypt using the ACME protocol. It will need to be run on your server 174 | and have access to your private account key, so PLEASE READ THROUGH IT! It's 175 | only ~200 lines, so it won't take long. 176 | 177 | ===Example Usage=== 178 | python acme_tiny.py --account-key ./account.key --csr ./domain.csr --acme-dir /usr/share/nginx/html/.well-known/acme-challenge/ > signed.crt 179 | =================== 180 | 181 | ===Example Crontab Renewal (once per month)=== 182 | 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.crt 2>> /var/log/acme_tiny.log 183 | ============================================== 184 | """) 185 | ) 186 | parser.add_argument("--account-key", required=True, help="path to your Let's Encrypt account private key") 187 | parser.add_argument("--csr", required=True, help="path to your certificate signing request") 188 | parser.add_argument("--acme-dir", required=True, help="path to the .well-known/acme-challenge/ directory") 189 | parser.add_argument("--quiet", action="store_const", const=logging.ERROR, help="suppress output except for errors") 190 | parser.add_argument("--ca", default=DEFAULT_CA, help="certificate authority, default is Let's Encrypt") 191 | 192 | args = parser.parse_args(argv) 193 | LOGGER.setLevel(args.quiet or LOGGER.level) 194 | signed_crt = get_crt(args.account_key, args.csr, args.acme_dir, log=LOGGER, CA=args.ca) 195 | sys.stdout.write(signed_crt) 196 | 197 | if __name__ == "__main__": # pragma: no cover 198 | main(sys.argv[1:]) 199 | --------------------------------------------------------------------------------