├── .gitignore ├── .travis.yml ├── Dockerfile ├── HISTORY.rst ├── MANIFEST.in ├── README.rst ├── setup.cfg ├── setup.py ├── src └── certsling │ ├── __init__.py │ ├── acme.py │ ├── acmesession.py │ ├── servers.py │ ├── tests │ ├── conftest.py │ ├── test_dnsserver.py │ ├── test_generate.py │ └── test_verify.py │ └── utils.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | /.cache/ 2 | /.coverage 3 | /.tox/ 4 | /bin/ 5 | /cov-*/ 6 | /dist/ 7 | /default.sublime-* 8 | /include/ 9 | /lib/ 10 | /pip-selfcheck.json 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.5" 4 | - "3.6" 5 | - "3.7" 6 | - "3.8" 7 | sudo: false 8 | install: pip install tox 9 | script: tox -e py${TRAVIS_PYTHON_VERSION/./} 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3-alpine 2 | 3 | RUN apk add --no-cache openssl libffi curl 4 | 5 | RUN apk add --no-cache openssl-dev musl-dev libffi-dev gcc \ 6 | && pip install certsling \ 7 | && apk del --no-cache openssl-dev musl-dev libffi-dev gcc 8 | 9 | EXPOSE 8080 8053 10 | 11 | WORKDIR /certsling 12 | VOLUME /certsling 13 | 14 | ENTRYPOINT ["certsling"] 15 | CMD ["--help"] 16 | -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | 0.11.0 - Unreleased 5 | ------------------- 6 | 7 | * Added support for ECDSA keys. 8 | [fschulze] 9 | 10 | * Added new Let's Encrypt issuers. 11 | [fschulze] 12 | 13 | 14 | 0.10.0 - 2022-02-17 15 | ------------------- 16 | 17 | * Drop Python 3.5 and 3.6 support, add Python 3.9 and 3.10. 18 | [fschulze] 19 | 20 | * Add option to always update with current settings without asking. 21 | [fschulze] 22 | 23 | * Updates for new root certificates. 24 | [fschulze] 25 | 26 | * Output more info for failed authorizations. 27 | [fschulze] 28 | 29 | 30 | 0.9.1 - 2020-08-23 31 | ------------------ 32 | 33 | * Accept return code 200 for nonce request. 34 | [witsch] 35 | 36 | 37 | 0.9.0 - 2020-06-14 38 | ------------------ 39 | 40 | * Switch to ACME Version 2 aka RFC 8555 protocol. 41 | [fschulze] 42 | 43 | * Enable ``-h`` for command line help output. 44 | [fschulze] 45 | 46 | * Add option to disable HTTP challenge. 47 | [fschulze] 48 | 49 | * Only start servers for enabled challenges. 50 | [fschulze] 51 | 52 | * Drop Python 3.4 support. 53 | Python 3.5 support will end at it's EOL in September 2020. 54 | [fschulze] 55 | 56 | * Exit when no domain was provided. 57 | [fschulze] 58 | 59 | * Add ``-y`` option to automatically answer yes for any question. 60 | 61 | 62 | 0.8.0 - 2017-01-04 63 | ------------------ 64 | 65 | * Add new ``--update`` (``-u``) option to avoid having to remember the settings 66 | for each domain. 67 | [fschulze] 68 | 69 | * Ask to repeat csr and crt generation on failure. 70 | [solidgoldbomb] 71 | 72 | * Switch to dnspython after it merged with dnspython3. 73 | [fschulze] 74 | 75 | 76 | 0.7.0 - 2016-12-30 77 | ------------------ 78 | 79 | * Renamed to ``certsling``. 80 | [fschulze] 81 | 82 | * Use symmetric difference in ``verify_domains``. This catches problems due to 83 | typos in domain names and some other cases. 84 | [solidgoldbomb] 85 | 86 | * Update list of issuer names checked in ``verify_crt``. 87 | [solidgoldbomb (Stacey Sheldon)] 88 | 89 | * More detailed error reporting. 90 | [fschulze] 91 | 92 | * Ask to agree to terms of use of letsencrypt and allow updating the registration. 93 | [fschulze] 94 | 95 | 96 | 0.6.0 - 2016-05-09 97 | ------------------ 98 | 99 | * Upgrade to new X3 authority. 100 | [fschulze] 101 | 102 | 103 | 0.5.0 - 2016-02-12 104 | ------------------ 105 | 106 | * Allow selection of letsencrypt.org staging server with ``-s`` option. 107 | [fschulze] 108 | 109 | 110 | 0.4.1 - 2016-01-29 111 | ------------------ 112 | 113 | * Fix issue that the ``-chained.crt`` file wasn't updated. 114 | [fschulze] 115 | 116 | 117 | 0.4.0 - 2016-01-12 118 | ------------------ 119 | 120 | * Initial release 121 | [fschulze] 122 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.ini *.rst 2 | include src/certsling/tests/*.py 3 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | certsling 2 | ========= 3 | 4 | An opinionated script to sign tls keys via `letsencrypt`_ on your local computer by forwarding the HTTP/DNS challenge via ssh. 5 | 6 | .. _certsling: https://pypi.python.org/pypi/certsling 7 | .. _letsencrypt: https://letsencrypt.org 8 | 9 | 10 | Installation 11 | ------------ 12 | 13 | Best installed via `pipsi`_:: 14 | 15 | % pipsi install certsling 16 | 17 | Or some other way to install a python package with included scripts. 18 | 19 | .. _pipsi: https://pypi.python.org/pypi/pipsi 20 | 21 | 22 | Requirements 23 | ------------ 24 | 25 | You need an ``openssl`` executable in your path for key generation and signing. 26 | 27 | 28 | Testing with staging server 29 | --------------------------- 30 | 31 | With the ``-s`` option you can use the staging server of `letsencrypt`_. 32 | This is advised, so you don't run into quota limits or similar until your setup works. 33 | The resulting certificate won't validate, but otherwise has the same content as a regular certificate. 34 | 35 | 36 | Basic usage 37 | ----------- 38 | 39 | Create a directory with the email address as the name, which you want to use for authentication with letsencrypt. 40 | For example ``webmaster@example.com``:: 41 | 42 | % mkdir webmaster@example.com 43 | 44 | Create a ssh connection to your server which forwards a remote port to the local port ``8080``:: 45 | 46 | % ssh root@example.com -R 8080:localhost:8080 47 | 48 | On your server the webserver needs to proxy requests to ``example.com:80/.well-known/acme-challenge/*`` to that forwarded port. 49 | An example for nginx:: 50 | 51 | location /.well-known/acme-challenge/ { 52 | proxy_pass http://localhost:8080; 53 | } 54 | 55 | From the directory you created earlier, invoke the ``certsling`` script with for example:: 56 | 57 | % cd webmaster@example.com 58 | % certsling example.com www.example.com 59 | 60 | On first run, you are asked whether to create a ``user.key`` for authorization with letsencrypt. 61 | 62 | After that, challenges for the selected domains are created and a server is started on port ``8080`` to provide responses. 63 | Your remote web server proxies them through the ssh connection to the locally running server. 64 | 65 | If all went well, you get a server key and certificate in a new ``example.com`` folder:: 66 | 67 | % ls example.com 68 | ... 69 | example.com-chained.crt 70 | example.com.crt 71 | example.com.key 72 | 73 | The ``example.com-chained.crt`` file contains the full chain of you certificate together with the letsencrypt certificate. 74 | 75 | 76 | Advanced usage 77 | -------------- 78 | 79 | To use DNS based authentication, you need to have ``socat`` on your server. 80 | Additionally you need to setup your DNS, so it delegates ``_acme-challenge`` requests to your server. 81 | For that you can add something similar to this to your zone file or equivalent:: 82 | 83 | _acme-challenge IN NS www 84 | _acme-challenge.www IN NS www 85 | 86 | For the forwarding, you need to add port ``8053``:: 87 | Create a ssh connection to your server which forwards a remote port to the local port ``8080``:: 88 | 89 | % ssh root@example.com -R 8080:localhost:8080 -R 8053:localhost:8053 90 | 91 | Then in that ssh session, run the following to forward UDP port ``53`` to TCP on port ``8053``:: 92 | 93 | # socat -T15 udp4-recvfrom:53,reuseaddr,fork tcp:localhost:8053 94 | 95 | For ``certsling`` you need to add the `--dns`` option:: 96 | 97 | % certsling --dns example.com www.example.com 98 | 99 | It will then first try the HTTP challenge and if that fails it will try the DNS challenge. 100 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [devpi:upload] 2 | formats = sdist.tgz,bdist_wheel 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | import os 3 | 4 | 5 | README = open(os.path.abspath('README.rst')).read() 6 | HISTORY = open(os.path.abspath('HISTORY.rst')).read() 7 | 8 | 9 | setup( 10 | name='certsling', 11 | version='0.10.0', 12 | description='Opinionated letsencrypt acme client working via a ssh port forward.', 13 | long_description="\n\n".join([README, HISTORY]), 14 | url='https://github.com/fschulze/certsling', 15 | classifiers=[ 16 | "Development Status :: 4 - Beta", 17 | "Intended Audience :: System Administrators", 18 | "License :: OSI Approved :: MIT License", 19 | "Programming Language :: Python", 20 | "Programming Language :: Python :: 3 :: Only", 21 | "Programming Language :: Python :: 3.7", 22 | "Programming Language :: Python :: 3.8", 23 | "Programming Language :: Python :: 3.9", 24 | "Programming Language :: Python :: 3.10"], 25 | install_requires=[ 26 | 'click', 27 | 'dnspython>=1.15.0', 28 | 'pyOpenSSL', 29 | 'requests'], 30 | entry_points={ 31 | 'console_scripts': ['certsling = certsling:main']}, 32 | packages=['certsling'], 33 | package_dir={'': 'src'}, 34 | python_requires='>=3.7') 35 | -------------------------------------------------------------------------------- /src/certsling/__init__.py: -------------------------------------------------------------------------------- 1 | from . import acme 2 | from . import acmesession 3 | from .servers import Tokens, start_servers 4 | from .utils import fatal, yesno as _yesno 5 | from .utils import _file_generator 6 | from .utils import _dated_file_generator 7 | from functools import partial 8 | from pathlib import Path 9 | from pprint import pprint 10 | import OpenSSL 11 | import click 12 | import datetime 13 | import json 14 | import subprocess 15 | import time 16 | 17 | 18 | DEFAULT_ECDSA_CURVE = "prime256v1" 19 | DEFAULT_RSA_LEN = 4096 20 | OPENSSL = 'openssl' 21 | LETSENCRYPT_CERT = 'lets-encrypt-x3-cross-signed' 22 | LETSENCRYPT_ISSUERS = frozenset([ 23 | "E1", "E2", "R3", "R4", 24 | "E5", "E6", "E7", "E8", "E9", 25 | "R10", "R11", "R12", "R13", "R14"]) 26 | 27 | 28 | def genecdsakey(fn, yesno, ask=False, keycurve=DEFAULT_ECDSA_CURVE): 29 | if ask: 30 | click.echo('There is no user key in the current directory %s.' % fn.parent) 31 | if not yesno('Do you want to create a user key?', default=False): 32 | fatal('No user key created') 33 | subprocess.check_call([ 34 | OPENSSL, 'ecparam', '-genkey', '-out', str(fn), '-name', keycurve]) 35 | 36 | 37 | def genecdsapub(fn, key): 38 | subprocess.check_call([ 39 | OPENSSL, 'ec', '-in', str(key), '-pubout', '-out', str(fn)]) 40 | 41 | 42 | def genrsakey(fn, yesno, ask=False, keylen=DEFAULT_RSA_LEN): 43 | if ask: 44 | click.echo('There is no user key in the current directory %s.' % fn.parent) 45 | if not yesno('Do you want to create a user key?', default=False): 46 | fatal('No user key created') 47 | subprocess.check_call([ 48 | OPENSSL, 'genrsa', '-out', str(fn), str(keylen)]) 49 | 50 | 51 | def genrsapub(fn, key): 52 | subprocess.check_call([ 53 | OPENSSL, 'rsa', '-in', str(key), '-pubout', '-out', str(fn)]) 54 | 55 | 56 | def createSubjectAltName(domains): 57 | return 'subjectAltName = %s' % ','.join('DNS:%s' % x for x in domains) 58 | 59 | 60 | def gencsr(fn, key, domains): 61 | if len(domains) > 1: 62 | config_fn = fn.parent.joinpath('openssl.cnf') 63 | with config_fn.open('wb') as config: 64 | lines = [ 65 | '[ req ]', 66 | 'distinguished_name = req_distinguished_name', 67 | '', 68 | '[ req_distinguished_name ]', 69 | '', 70 | '[SAN]', 71 | createSubjectAltName(domains), 72 | ''] 73 | config.write(bytes('\n'.join(lines).encode('ascii'))) 74 | subprocess.check_call([ 75 | OPENSSL, 'req', '-sha256', '-new', 76 | '-key', str(key), '-out', str(fn), '-subj', '/', 77 | '-reqexts', 'SAN', '-config', str(config_fn)]) 78 | else: 79 | subprocess.check_call([ 80 | OPENSSL, 'req', '-sha256', '-new', 81 | '-key', str(key), '-out', str(fn), 82 | '-subj', '/CN=%s' % domains[0]]) 83 | 84 | 85 | def verify_domains(cert_or_req, domains): 86 | names = set() 87 | subject = dict(cert_or_req.get_subject().get_components()).get( 88 | b'CN', b'').decode('ascii') 89 | if subject: 90 | names.add(subject) 91 | if hasattr(cert_or_req, 'get_extensions'): 92 | extensions = cert_or_req.get_extensions() 93 | else: 94 | extensions = [ 95 | cert_or_req.get_extension(x) 96 | for x in range(cert_or_req.get_extension_count())] 97 | for ext in extensions: 98 | if ext.get_short_name() != b'subjectAltName': 99 | continue 100 | alt_names = [ 101 | x.strip().replace('DNS:', '') 102 | for x in str(ext).split(',')] 103 | names = names.union(alt_names) 104 | unmatched = set(domains).symmetric_difference(names) 105 | if unmatched: 106 | click.echo(click.style( 107 | "Unmatched alternate names %s" % ', '.join(unmatched), fg="red")) 108 | return False 109 | return True 110 | 111 | 112 | def verify_csr(csr, domains): 113 | subprocess.check_call([ 114 | OPENSSL, 'req', '-noout', '-verify', '-in', str(csr)]) 115 | with csr.open('rb') as f: 116 | req = OpenSSL.crypto.load_certificate_request( 117 | OpenSSL.crypto.FILETYPE_PEM, f.read()) 118 | if not verify_domains(req, domains): 119 | subprocess.check_call([ 120 | OPENSSL, 'req', '-noout', '-text', '-in', str(csr)]) 121 | return False 122 | return True 123 | 124 | 125 | def gender(fn, csr): 126 | subprocess.check_call([ 127 | OPENSSL, 'req', '-outform', 'DER', '-out', str(fn), '-in', str(csr)]) 128 | 129 | 130 | def check_acme_registration(genreg, jwk, file_generator): 131 | click.echo("Checking registration at letsencrypt.") 132 | reg_fn = file_generator( 133 | 'registration info', '.json', genreg) 134 | with reg_fn.open() as f: 135 | registration_info = json.load(f) 136 | click.echo("Registered on %s via %s" % ( 137 | registration_info["createdAt"], registration_info["initialIp"])) 138 | click.echo("Contact: %s" % ", ".join(registration_info["contact"])) 139 | if 'agreement' in registration_info: 140 | click.echo("Agreement: %s" % registration_info["agreement"]) 141 | if registration_info['key'] != jwk: 142 | fatal("The public user key and the registration info don't match.") 143 | 144 | 145 | def _genreg(fn, acme, email): 146 | data = acme.new_account(email) 147 | data = json.dumps(data, sort_keys=True, indent=4) 148 | with fn.open('w') as out: 149 | out.write(data) 150 | 151 | 152 | def gencrt(fn, acme_factory, check_registration, der, user_pub, email, domains): 153 | with der.open('rb') as f: 154 | der_data = f.read() 155 | acme = acme_factory() 156 | genreg = partial(_genreg, acme=acme, email=email) 157 | check_registration(genreg) 158 | click.echo("Preparing challenges for %s." % ', '.join(domains)) 159 | (order_uri, info) = acme.handle_order(domains) 160 | if info['status'] == 'pending': 161 | for authorization in info['authorizations']: 162 | acme.handle_authorization(authorization) 163 | count = 0 164 | while info['status'] == 'pending': 165 | while count < 5: 166 | authorizations_ok = [ 167 | acme.tokens.get_status(authorization) in ('requested', 'valid') 168 | for authorization in info['authorizations']] 169 | if all(authorizations_ok): 170 | break 171 | click.echo(".", nl=False) 172 | time.sleep(1) 173 | count += 1 174 | info = acme.poll_order(order_uri) 175 | if info.get('status') == 'ready': 176 | info = acme.finalize_order(info['finalize'], der_data) 177 | if info.get('status') == 'valid': 178 | cert = acme.get_certificate(info['certificate']) 179 | with fn.open('wb') as out: 180 | out.write(cert) 181 | elif info.get('status') == 'invalid': 182 | for authorization in info['authorizations']: 183 | print("Authorization %s" % authorization) 184 | pprint(acme.get_authorization_info(authorization)) 185 | pprint(info) 186 | fatal("Failed to get certificate.") 187 | else: 188 | pprint(info) 189 | fatal("Failed to get certificate.") 190 | 191 | 192 | def verify_crt(crt, domains): 193 | with crt.open('rb') as f: 194 | cert = OpenSSL.crypto.load_certificate( 195 | OpenSSL.crypto.FILETYPE_PEM, f.read()) 196 | issuer = dict(cert.get_issuer().get_components()).get( 197 | b'CN', b'unknown').decode('ascii') 198 | organisation = dict(cert.get_issuer().get_components()).get( 199 | b'O', b'unknown').decode('ascii') 200 | if issuer in ["happy hacker fake CA", 201 | "Fake LE Intermediate X1"]: 202 | click.echo(click.style("Certificate issued by staging CA!", fg="red")) 203 | elif issuer in ["Let's Encrypt Authority X1", 204 | "Let's Encrypt Authority X2", 205 | "Let's Encrypt Authority X3", 206 | "Let's Encrypt Authority X4"]: 207 | click.echo(click.style( 208 | "Certificate issued by: %s %s" % (organisation, issuer), 209 | fg="green")) 210 | elif organisation == "Let's Encrypt" and issuer in LETSENCRYPT_ISSUERS: 211 | click.echo(click.style( 212 | "Certificate issued by: %s %s" % (organisation, issuer), 213 | fg="green")) 214 | else: 215 | click.echo(click.style( 216 | "Unknown CA: %s %s" % (organisation, issuer), 217 | fg="red")) 218 | if not verify_domains(cert, domains): 219 | subprocess.check_call([ 220 | OPENSSL, 'x509', '-noout', '-text', '-in', str(crt)]) 221 | return False 222 | return True 223 | 224 | 225 | def chain(fn, crt, pem): 226 | with fn.open('wb') as out: 227 | for name in (crt, pem): 228 | with name.open('rb') as f: 229 | data = f.read() 230 | out.write(data) 231 | if not data.endswith(b'\n'): 232 | out.write(b'\n') 233 | 234 | 235 | def remove(yesno, base, *patterns): 236 | files = [] 237 | for pattern in patterns: 238 | for fn in base.glob(pattern): 239 | files.append(fn) 240 | click.echo(fn.relative_to(base)) 241 | if not yesno("Do you want to remove the above invalid files for a clean retry?"): 242 | fatal('Aborted.') 243 | for fn in files: 244 | if fn.exists(): 245 | fn.unlink() 246 | 247 | 248 | def generate(base, main, acme_factory, acme_uris_factory, domains, file_gens, yesno, keycurve, keylen, keytype): 249 | user_key = file_gens['file'](base, 'user')( 250 | 'private RSA user key', '.key', partial(genrsakey, keylen=DEFAULT_RSA_LEN), yesno=yesno, ask=True) 251 | user_pub = file_gens['file'](base, 'user')( 252 | 'public RSA user key', '.pub', partial(genrsapub), user_key) 253 | assert user_pub.parent == base 254 | key_base = base.joinpath(main) 255 | if not key_base.exists(): 256 | key_base.mkdir() 257 | if keytype == "rsa": 258 | key = file_gens['dated'](key_base, main)( 259 | 'key', '.key', partial(genrsakey, keylen=keylen), yesno=yesno) 260 | elif keytype == "ecdsa": 261 | key = file_gens['dated'](key_base, main)( 262 | 'key', '.key', partial(genecdsakey, keycurve=keycurve), yesno=yesno) 263 | else: 264 | raise RuntimeError("Invalid key type %r" % keytype) 265 | date_gen = file_gens['current'](key_base, main) 266 | while True: 267 | csr = date_gen('csr', '.csr', gencsr, key, domains) 268 | if verify_csr(csr, domains): 269 | break 270 | remove(yesno, key_base, '*.csr', '*.crt', '*.der') 271 | with user_key.open('rb') as f: 272 | priv = OpenSSL.crypto.load_privatekey( 273 | OpenSSL.crypto.FILETYPE_PEM, f.read()) 274 | jwk = acmesession.get_jwk(user_pub) 275 | session = acmesession.get_session(jwk, priv) 276 | check_registration = partial( 277 | check_acme_registration, 278 | jwk=jwk, 279 | file_generator=file_gens['registration'](base, 'registration')) 280 | while True: 281 | der = date_gen('der', '.der', gender, csr) 282 | assert der.parent == key_base 283 | acme_factory = partial( 284 | acme_factory, 285 | acme_uris=acme_uris_factory(session=session)) 286 | crt = date_gen('chained crt', '-chained.crt', gencrt, acme_factory, check_registration, der, user_pub, base.name, domains) 287 | if verify_crt(crt, domains): 288 | break 289 | remove(yesno, key_base, '*.crt', '*.der') 290 | 291 | 292 | def domain_key(x): 293 | return (len(x), x) 294 | 295 | 296 | @click.command(context_settings=dict(help_option_names=['-h', '--help'])) 297 | @click.option( 298 | "--dns/--no-dns", default=False, 299 | help="Enable/disable DNS challenge. HTTP challenge is tried first if enabled.") 300 | @click.option( 301 | "--http/--no-http", default=True, 302 | help="Enable/disable HTTP challenge.") 303 | @click.option( 304 | "-r/-R", "--regenerate/--dont-regenerate", default=False, 305 | help="Force creating a new certificate even if one for the current day exists.") 306 | @click.option( 307 | "-s/-p", "--staging/--production", default=False, 308 | help="Use staging server of letsencrypt.org for testing.") 309 | @click.option( 310 | "-u", "--update", metavar="PATH", 311 | help="Update the certificates in PATH.") 312 | @click.option( 313 | "--update-registration/--no-update-registration", default=False, 314 | help="Force an update of the registration, for example to agree to newer terms of service.") 315 | @click.option( 316 | "-y", "--yes/--ask", default=False, 317 | help="Answer yes to all questions.") 318 | @click.option( 319 | "--always-update/--ask-update", default=False, 320 | help="Answer yes when asked whether to update with current settings.") 321 | @click.option( 322 | "--keycurve", default=DEFAULT_ECDSA_CURVE, 323 | help="The curve to use for ecdsa keys. Default is prime256v1.") 324 | @click.option( 325 | "--keytype", default="rsa", 326 | type=click.Choice(["rsa", "ecdsa"], case_sensitive=False), 327 | help="Key type to use for the certificate.") 328 | @click.option( 329 | "--keylen", default=DEFAULT_RSA_LEN, 330 | type=int, 331 | help="Key length to use for RSA.") 332 | @click.argument("domains", metavar="[DOMAIN]...", nargs=-1) 333 | def main(domains, dns, http, keycurve, keylen, keytype, regenerate, staging, update, always_update, update_registration, yes): 334 | """Creates a certificate for one or more domains. 335 | 336 | By default a new certificate is generated, except when running again on 337 | the same day.""" 338 | update_yesno = partial(_yesno, always_yes=yes or always_update) 339 | yesno = partial(_yesno, always_yes=yes) 340 | if staging: 341 | ca = "https://acme-staging-v02.api.letsencrypt.org" 342 | else: 343 | ca = "https://acme-v02.api.letsencrypt.org" 344 | base = Path.cwd() 345 | date = datetime.date.today().strftime("%Y%m%d") 346 | current = 'force' if regenerate else True 347 | file_gens = dict( 348 | file=_file_generator, 349 | dated=partial(_dated_file_generator, date=date), 350 | current=partial(_dated_file_generator, date=date, current=current), 351 | registration=partial(_file_generator, update=update_registration)) 352 | cli_domains = sorted(domains, key=domain_key) 353 | challenges = ['http-01'] 354 | if http and 'http-01' not in challenges: 355 | challenges.append('http-01') 356 | if not http and 'http-01' in challenges: 357 | challenges.remove('http-01') 358 | if dns and 'dns-01' not in challenges: 359 | challenges.append('dns-01') 360 | if not dns and 'dns-01' in challenges: 361 | challenges.remove('dns-01') 362 | if not challenges: 363 | fatal("No challenge types enabled.") 364 | options = dict(challenges=challenges) 365 | if update: 366 | path = Path(update).absolute() 367 | if not path.exists(): 368 | fatal("The path %s doesn't exist." % update) 369 | base = path.parent 370 | options_path = path.joinpath('options.json') 371 | if options_path.exists(): 372 | with options_path.open() as f: 373 | options.update(json.load(f)) 374 | else: 375 | options['domains'] = [path.name] + [ 376 | x.name.rsplit('.authz_info.json', 1)[0] 377 | for x in path.glob('*.authz_info.json')] 378 | options['main'] = path.name 379 | option_domains = tuple( 380 | sorted(set(options.get('domains', [])), key=domain_key)) 381 | if option_domains: 382 | click.echo(click.style( 383 | "Existing domains from '%s': %s" % ( 384 | update, ", ".join(option_domains)), 385 | fg="green")) 386 | domains = tuple(set(domains).union(option_domains)) 387 | if cli_domains: 388 | click.echo(click.style( 389 | "Domains from command line: %s" % ", ".join(cli_domains), 390 | fg="green")) 391 | domains = tuple(set(domains).union(cli_domains)) 392 | domains = sorted(domains, key=domain_key) 393 | main = options.get('main', domains[0] if domains else None) 394 | tokens = Tokens() 395 | start_servers(challenges, tokens) 396 | if domains: 397 | click.echo(click.style( 398 | "Domains to update: %s" % ", ".join(domains), 399 | fg="green")) 400 | click.echo(click.style( 401 | "Main domain: %s" % main, 402 | fg="green")) 403 | click.echo(click.style( 404 | "Challenges: %s" % ", ".join(challenges), 405 | fg="green")) 406 | if update: 407 | if not update_yesno("Do you want to update with the above settings?"): 408 | fatal('Aborted.') 409 | acme_factory = partial( 410 | acme.ACME, 411 | challenges=challenges, 412 | tokens=tokens, 413 | yesno=yesno) 414 | acme_uris_factory = partial(acme.ACMEUris, ca=ca) 415 | generate( 416 | base, main, 417 | acme_factory, acme_uris_factory, 418 | domains, file_gens, yesno, keycurve, keylen, keytype) 419 | with base.joinpath(main, 'options.json').open("w") as f: 420 | f.write(json.dumps( 421 | dict( 422 | challenges=challenges, 423 | main=main, 424 | domains=domains), 425 | sort_keys=True, indent=4)) 426 | else: 427 | fatal( 428 | "No domains given.\n" 429 | "Use --help to print usage.") 430 | 431 | 432 | if __name__ == '__main__': 433 | main() 434 | -------------------------------------------------------------------------------- /src/certsling/acme.py: -------------------------------------------------------------------------------- 1 | from .acmesession import b64 2 | from .utils import fatal, fatal_response 3 | from functools import partial 4 | import click 5 | 6 | 7 | class ACMEUris: 8 | def __init__(self, ca, session): 9 | self.session = session 10 | res = session.get(ca + '/directory') 11 | content_type = res.headers.get('Content-Type') 12 | if res.status_code != 200 or content_type != 'application/json': 13 | fatal_response( 14 | "Couldn't get directory from CA server '%s'" % ca, res) 15 | self.uris = res.json() 16 | self.terms_uri = self.uris.get('meta', {}).get('termsOfService') 17 | self.new_account = partial(session.post_jwk_signed, self.uris['newAccount']) 18 | self.new_nonce = partial(session.head, self.uris['newNonce']) 19 | self.new_order = partial(session.post_kid_signed, self.uris['newOrder']) 20 | 21 | 22 | class ACME: 23 | def __init__(self, acme_uris, challenges, tokens, yesno): 24 | self.challenges = challenges 25 | self.acme_uris = acme_uris 26 | self.tokens = tokens 27 | self.yesno = yesno 28 | 29 | def authorized_token(self, token): 30 | return "{}.{}".format(token, self.acme_uris.session.thumbprint).encode('ascii') 31 | 32 | def ensure_account(self): 33 | self.ensure_nonce() 34 | if self.acme_uris.session.kid is None: 35 | res = self.acme_uris.new_account(onlyReturnExisting=True) 36 | if res.status_code != 200: 37 | fatal_response("Bad account check response", res) 38 | self.acme_uris.session.kid = res.headers['Location'] 39 | 40 | def ensure_nonce(self): 41 | if self.acme_uris.session.nonce is None: 42 | res = self.acme_uris.new_nonce() 43 | if res.status_code not in (200, 204): 44 | fatal_response("Bad newNonce response", res) 45 | 46 | def finalize_order(self, uri, der_data): 47 | res = self.acme_uris.session.post_kid_signed(uri, csr=b64(der_data)) 48 | if res.status_code != 200: 49 | fatal_response("Bad finalize order request", res) 50 | return res.json() 51 | 52 | def get_certificate(self, uri): 53 | res = self.acme_uris.session.post_as_get(uri) 54 | if res.status_code != 200: 55 | fatal_response("Bad get certificate request", res) 56 | return res.content 57 | 58 | def get_authorization_info(self, uri): 59 | res = self.acme_uris.session.post_as_get(uri) 60 | if res.status_code != 200: 61 | fatal_response("Bad authorization response", res) 62 | return res.json() 63 | 64 | def handle_authorization(self, uri): 65 | data = self.get_authorization_info(uri) 66 | if data.get('status') == 'valid': 67 | self.tokens.set_status(uri, 'valid') 68 | return 69 | domain = data['identifier']['value'] 70 | click.echo("Authorizing %s." % domain) 71 | for challenge_type in self.challenges: 72 | for challenge in data['challenges']: 73 | if challenge['type'] != challenge_type: 74 | continue 75 | authorized_token = self.authorized_token(challenge['token']) 76 | if challenge_type == 'dns-01': 77 | self.tokens.add_dns_reply( 78 | uri, domain, authorized_token) 79 | elif challenge_type == 'http-01': 80 | self.tokens.add_http_reply( 81 | uri, challenge['token'], authorized_token) 82 | res = self.acme_uris.session.post_kid_signed(challenge['url']) 83 | if res.status_code != 200: 84 | fatal_response("Bad challenge request %s" % challenge, res) 85 | 86 | def handle_order(self, domains): 87 | self.ensure_account() 88 | res = self.acme_uris.new_order(identifiers=[ 89 | dict(type="dns", value=domain) 90 | for domain in domains]) 91 | if res.status_code != 201: 92 | fatal_response("Bad newOrder response", res) 93 | return res.headers['Location'], res.json() 94 | 95 | def new_account(self, email): 96 | self.ensure_nonce() 97 | question = "Do you agree to the terms of service" 98 | if self.acme_uris.terms_uri: 99 | question = "%s at %s" % (question, self.acme_uris.terms_uri) 100 | if not self.yesno(question): 101 | fatal("Didn't agree to terms of service.") 102 | res = self.acme_uris.new_account( 103 | contact=["mailto:" + email], 104 | termsOfServiceAgreed=True) 105 | if res.status_code == 201: 106 | return res.json() 107 | fatal_response("Bad newAccount response", res) 108 | 109 | def poll_order(self, uri): 110 | res = self.acme_uris.session.post_as_get(uri) 111 | if res.status_code != 200: 112 | fatal_response("Bad order poll response", res) 113 | return res.json() 114 | -------------------------------------------------------------------------------- /src/certsling/acmesession.py: -------------------------------------------------------------------------------- 1 | from base64 import urlsafe_b64encode 2 | from cryptography.hazmat.backends import default_backend 3 | from cryptography.hazmat.primitives import serialization 4 | from functools import partial 5 | import OpenSSL 6 | import binascii 7 | import hashlib 8 | import json 9 | import requests 10 | 11 | 12 | def b64(data): 13 | return urlsafe_b64encode(data).replace(b"=", b"").decode('ascii') 14 | 15 | 16 | def _leading_zeros(arg): 17 | if len(arg) % 2: 18 | return '0' + arg 19 | return arg 20 | 21 | 22 | def _encode(data): 23 | return b64(binascii.unhexlify( 24 | _leading_zeros(hex(data)[2:].rstrip('L')))) 25 | 26 | 27 | def get_jwk(user_pub): 28 | backend = default_backend() 29 | with user_pub.open('rb') as f: 30 | pub = serialization.load_pem_public_key(f.read(), backend) 31 | pub_numbers = pub.public_numbers() 32 | return dict( 33 | kty="RSA", 34 | e=_encode(pub_numbers.e), 35 | n=_encode(pub_numbers.n)) 36 | 37 | 38 | def get_thumbprint(jwk): 39 | return b64(hashlib.sha256(json.dumps( 40 | jwk, 41 | sort_keys=True, 42 | separators=(',', ':')).encode('ascii')).digest()) 43 | 44 | 45 | def protected(header, uri, nonce, kid): 46 | data = dict(header) 47 | data['nonce'] = nonce 48 | data['url'] = uri 49 | if kid is not None: 50 | data.pop('jwk') 51 | data['kid'] = kid 52 | return b64(json.dumps(data, sort_keys=True, indent=4).encode('utf-8')) 53 | 54 | 55 | def sign_sha256(sig_data, priv): 56 | return OpenSSL.crypto.sign( 57 | priv, sig_data.encode('ascii'), 'sha256') 58 | 59 | 60 | def dumps(**kw): 61 | return b64(json.dumps(dict(**kw), sort_keys=True).encode('utf-8')) 62 | 63 | 64 | def _dumps_signed(nonce, uri, header, payload, sign, kid=None): 65 | data = protected(header, uri, nonce, kid) 66 | sig_data = "%s.%s" % (data, payload) 67 | signature = b64(sign(sig_data)) 68 | data = dict( 69 | protected=data, 70 | payload=payload, 71 | signature=signature) 72 | return data 73 | 74 | 75 | class ACMESession: 76 | def __init__(self, dumps_signed, thumbprint): 77 | self.dumps_signed = dumps_signed 78 | self.kid = None 79 | self.nonce = None 80 | self.thumbprint = thumbprint 81 | self.session = requests.Session() 82 | self.session.hooks = dict(response=self.response_hook) 83 | self.get = self.session.get 84 | self.head = self.session.head 85 | self.post = self.session.post 86 | 87 | def response_hook(self, response, *args, **kwargs): 88 | if 'Replay-Nonce' in response.headers: 89 | self.nonce = response.headers['Replay-Nonce'] 90 | 91 | def post_jwk_signed(self, uri, **kw): 92 | payload = dumps(**kw) 93 | return self.session.post( 94 | uri, 95 | headers={'Content-Type': 'application/jose+json'}, 96 | json=self.dumps_signed(self.nonce, uri, payload=payload)) 97 | 98 | def post_kid_signed(self, uri, **kw): 99 | payload = dumps(**kw) 100 | return self.session.post( 101 | uri, 102 | headers={'Content-Type': 'application/jose+json'}, 103 | json=self.dumps_signed(self.nonce, uri, payload=payload, kid=self.kid)) 104 | 105 | def post_as_get(self, uri): 106 | return self.session.post( 107 | uri, 108 | headers={'Content-Type': 'application/jose+json'}, 109 | json=self.dumps_signed(self.nonce, uri, payload="", kid=self.kid)) 110 | 111 | 112 | def get_session(jwk, priv): 113 | dumps_signed = partial( 114 | _dumps_signed, 115 | header=dict(alg="RS256", jwk=dict(jwk)), 116 | sign=partial(sign_sha256, priv=priv)) 117 | thumbprint = get_thumbprint(jwk) 118 | return ACMESession(dumps_signed, thumbprint) 119 | -------------------------------------------------------------------------------- /src/certsling/servers.py: -------------------------------------------------------------------------------- 1 | from .acmesession import b64 2 | from .utils import fatal 3 | import click 4 | import dns.name 5 | import dns.message 6 | import dns.rdtypes.ANY.TXT 7 | import dns.rrset 8 | import hashlib 9 | import http.server 10 | import socket 11 | import threading 12 | import time 13 | 14 | 15 | class Tokens(dict): 16 | def __init__(self, *args, **kwargs): 17 | super().__init__(*args, **kwargs) 18 | self._status = {} 19 | 20 | def add_dns_reply(self, uri, domain, authorized_token): 21 | digest = hashlib.sha256(authorized_token).digest() 22 | txt = b64(digest) 23 | click.echo('_acme-challenge.%s. IN TXT "%s"' % (domain, txt)) 24 | self[dns.name.from_text(domain)] = (uri, txt) 25 | 26 | def add_http_reply(self, uri, token, authorized_token): 27 | self[token] = (uri, authorized_token) 28 | 29 | def set_status(self, uri, status): 30 | self._status[uri] = status 31 | 32 | def get_status(self, uri): 33 | return self._status.get(uri, 'unknown') 34 | 35 | 36 | class HTTPRequestHandler(http.server.BaseHTTPRequestHandler): 37 | def do_GET(self): 38 | parts = self.path.split('/') 39 | if len(parts) != 4 or parts[1:3] != ['.well-known', 'acme-challenge']: 40 | self.send_response(404) 41 | self.end_headers() 42 | return 43 | token = parts[3] 44 | if token not in self.server.tokens: 45 | self.send_response(404) 46 | self.end_headers() 47 | return 48 | self.send_response(200) 49 | self.end_headers() 50 | (uri, reply) = self.server.tokens[token] 51 | self.wfile.write(reply) 52 | self.server.tokens.set_status(uri, 'requested') 53 | 54 | 55 | class DNSServer: 56 | def __init__(self, address): 57 | self.address = address 58 | 59 | def handle_request(self, addr, data): 60 | query = dns.message.from_wire(data) 61 | response = dns.message.make_response(query) 62 | for question in query.question: 63 | if question.rdtype != 16: 64 | continue 65 | if question.name.labels[0].lower() != b'_acme-challenge': 66 | continue 67 | domain = question.name.parent() 68 | print('Got request from %s for domain %s.' % (addr, domain)) 69 | if domain not in self.tokens: 70 | continue 71 | (uri, txt) = self.tokens[domain] 72 | print('Answering with "%s" to %s for domain %s.' % (txt, addr, domain)) 73 | response.answer.append(dns.rrset.from_rdata( 74 | question.name, 75 | 0, 76 | dns.rdtypes.ANY.TXT.TXT( 77 | question.rdclass, question.rdtype, [txt.encode('ascii')]))) 78 | self.tokens.set_status(uri, 'requested') 79 | return response.to_wire() 80 | 81 | def __call__(self): 82 | s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 83 | try: 84 | s.bind(self.address) 85 | except OSError as e: 86 | fatal(str(e)) 87 | s.listen(1) 88 | while 1: 89 | conn, addr = s.accept() 90 | with conn: 91 | data = conn.recv(1024) 92 | reply = self.handle_request(addr, data) 93 | if reply is not None: 94 | conn.send(reply) 95 | 96 | 97 | def start_servers(challenges, tokens): 98 | if any(x.startswith('http') for x in challenges): 99 | address = ('localhost', 8080) 100 | server = http.server.HTTPServer(address, HTTPRequestHandler) 101 | thread = threading.Thread(target=server.serve_forever, daemon=True) 102 | click.echo("Starting http server on %s:%s" % address) 103 | thread.start() 104 | time.sleep(0.1) 105 | if not thread.is_alive(): 106 | fatal("Failed to start HTTP server on port 8080.") 107 | server.tokens = tokens 108 | if any(x.startswith('dns') for x in challenges): 109 | dnsaddress = ('localhost', 8053) 110 | dnsserver = DNSServer(dnsaddress) 111 | dnsthread = threading.Thread(target=dnsserver, daemon=True) 112 | click.echo("Starting dns server on %s:%s" % dnsaddress) 113 | dnsthread.start() 114 | time.sleep(0.1) 115 | if not dnsthread.is_alive(): 116 | fatal("Failed to start DNS server on port 8053.") 117 | dnsserver.tokens = tokens 118 | -------------------------------------------------------------------------------- /src/certsling/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import binascii 3 | import http.server 4 | import json 5 | import pytest 6 | import requests 7 | import threading 8 | import time 9 | 10 | 11 | def d64(data): 12 | for pad in ('', '=', '=='): 13 | try: 14 | return base64.urlsafe_b64decode(data + pad) 15 | except binascii.Error: 16 | pass 17 | 18 | 19 | class HTTPRequestHandler(http.server.BaseHTTPRequestHandler): 20 | def make_url(self, path): 21 | addr = "http://%s:%s/{0}" % self.server.socket.getsockname() 22 | return addr.format(path) 23 | 24 | def do_GET(self): 25 | if self.path == '/directory': 26 | self.send_response(200) 27 | self.send_header('Replay-Nonce', 'nonce') 28 | methods = ['newAccount', 'newNonce', 'newOrder'] 29 | self.write_response({x: self.make_url(x) for x in methods}) 30 | else: 31 | raise ValueError("GET %s" % self.path) 32 | 33 | def do_HEAD(self): 34 | if self.path == '/newNonce': 35 | self.send_response(200) 36 | self.send_header('Replay-Nonce', 'nonce') 37 | self.end_headers() 38 | else: 39 | raise ValueError("POST %s" % self.path) 40 | 41 | @property 42 | def data(self): 43 | if not hasattr(self, '_data'): 44 | data = self.rfile.read(int(self.headers['Content-Length'])) 45 | self._data = json.loads(data.decode('utf-8')) 46 | return self._data 47 | 48 | def get_payload(self): 49 | payload = d64(self.data['payload']) 50 | return json.loads(payload.decode('utf-8')) 51 | 52 | def get_protected(self): 53 | protected = d64(self.data['protected']) 54 | return json.loads(protected.decode('utf-8')) 55 | 56 | def write_response(self, response): 57 | response = json.dumps(response) 58 | self.send_header('Content-Type', 'application/json') 59 | self.end_headers() 60 | self.wfile.write(response.encode('ascii')) 61 | 62 | def do_POST(self): 63 | if self.path == '/newOrder': 64 | payload = self.get_payload() 65 | self.send_response(201) 66 | self.send_header('Location', '') 67 | self.write_response({ 68 | 'status': 'valid', 69 | 'certificate': self.make_url('certificate')}) 70 | elif self.path == '/newAccount': 71 | payload = self.get_payload() 72 | protected = self.get_protected() 73 | if 'onlyReturnExisting' in payload: 74 | if protected['jwk']['n'] in self.server.accounts: 75 | self.send_response(200) 76 | self.send_header('Location', self.make_url('account/1')) 77 | self.end_headers() 78 | else: 79 | self.send_response(400) 80 | self.end_headers() 81 | else: 82 | self.send_response(201) 83 | self.write_response({ 84 | 'createdAt': 'createdAt', 85 | 'initialIp': 'initialIp', 86 | 'contact': payload['contact'], 87 | 'key': protected['jwk']}) 88 | self.server.accounts[protected['jwk']['n']] = True 89 | elif self.path == '/certificate': 90 | self.send_response(200) 91 | self.end_headers() 92 | else: 93 | raise ValueError("POST %s" % self.path) 94 | 95 | def log_request(self, code): 96 | return 97 | 98 | 99 | def wait_for_http(method, url, timeout=60): 100 | session = requests.Session() 101 | while timeout > 0: 102 | try: 103 | getattr(session, method.lower())(url, timeout=1) 104 | except ConnectionError: 105 | time.sleep(1) 106 | timeout -= 1 107 | else: 108 | return 109 | raise RuntimeError( 110 | f"The request {method} {url} didn't become accessible") 111 | 112 | 113 | @pytest.fixture 114 | def server(): 115 | address = ('localhost', 0) 116 | server = http.server.HTTPServer(address, HTTPRequestHandler) 117 | server.accounts = {} 118 | thread = threading.Thread(target=server.serve_forever, daemon=True) 119 | thread.start() 120 | wait_for_http('HEAD', "http://%s:%s/newNonce" % server.socket.getsockname()) 121 | yield server 122 | server.shutdown() 123 | thread.join() 124 | 125 | 126 | @pytest.fixture 127 | def ca(server): 128 | return "http://%s:%s" % server.socket.getsockname() 129 | 130 | 131 | @pytest.fixture(autouse=True) 132 | def genkey(monkeypatch): 133 | from certsling import genrsakey 134 | from functools import partial 135 | genkey_partial = partial(genrsakey, keylen=512) 136 | monkeypatch.setattr("certsling.genrsakey", genkey_partial) 137 | return genkey_partial 138 | 139 | 140 | @pytest.fixture 141 | def base(tmpdir): 142 | from pathlib import Path 143 | return Path(tmpdir.ensure_dir('foo@example.com').strpath) 144 | 145 | 146 | @pytest.fixture 147 | def verify_crt_true(monkeypatch): 148 | monkeypatch.setattr("certsling.verify_crt", lambda *x: True) 149 | -------------------------------------------------------------------------------- /src/certsling/tests/test_dnsserver.py: -------------------------------------------------------------------------------- 1 | def test_dns(): 2 | from certsling.servers import DNSServer 3 | from certsling.servers import Tokens 4 | import dns.name 5 | request = b'\xed:\x00\x10\x00\x01\x00\x00\x00\x00\x00\x01\x0f_acMe-cHaLlenge\tLOcaLHOSt\x07ExaMPle\x03com\x00\x00\x10\x00\x01\x00\x00)\x10\x00\x00\x00\x80\x00\x00\x00' 6 | server = DNSServer(('127.0.0.1', 8053)) 7 | server.tokens = Tokens() 8 | server.tokens[dns.name.from_text('localhost.example.com')] = ('uri', 'foo') 9 | reply = server.handle_request('192.168.1.42', request) 10 | assert reply == b'\xed:\x80\x00\x00\x01\x00\x01\x00\x00\x00\x01\x0f_acMe-cHaLlenge\tLOcaLHOSt\x07ExaMPle\x03com\x00\x00\x10\x00\x01\xc0\x0c\x00\x10\x00\x01\x00\x00\x00\x00\x00\x04\x03foo\x00\x00) \x00\x00\x00\x00\x00\x00\x00' 11 | -------------------------------------------------------------------------------- /src/certsling/tests/test_generate.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | import pytest 3 | 4 | 5 | @pytest.fixture 6 | def acme_factory(ca): 7 | from certsling.acme import ACME 8 | return partial( 9 | ACME, 10 | challenges=[], 11 | tokens={}, 12 | yesno=lambda m: True) 13 | 14 | 15 | @pytest.fixture 16 | def acme_uris_factory(ca): 17 | from certsling.acme import ACMEUris 18 | return partial( 19 | ACMEUris, 20 | ca=ca) 21 | 22 | 23 | @pytest.fixture 24 | def generate(acme_factory, acme_uris_factory, base): 25 | from certsling import generate 26 | return partial( 27 | generate, 28 | acme_factory=acme_factory, 29 | acme_uris_factory=acme_uris_factory, 30 | base=base) 31 | 32 | 33 | def get_file_gens(date=None, regenerate=False, update_registration=False): 34 | from certsling import _file_generator, _dated_file_generator 35 | import datetime 36 | 37 | if date is None: 38 | date = datetime.date.today().strftime("%Y%m%d") 39 | current = 'force' if regenerate else True 40 | return dict( 41 | file=_file_generator, 42 | dated=partial(_dated_file_generator, date=date), 43 | current=partial(_dated_file_generator, date=date, current=current), 44 | registration=partial(_file_generator, update=update_registration)) 45 | 46 | 47 | def test_user_gen(base, generate, verify_crt_true): 48 | assert list(base.iterdir()) == [] 49 | domains = ['example.com'] 50 | generate( 51 | main=domains[0], domains=domains, 52 | file_gens=get_file_gens(), 53 | yesno=lambda m, default=None: True, 54 | keycurve=None, keylen=512, keytype="rsa") 55 | fns = list(x.name for x in base.iterdir()) 56 | assert 'user.key' in fns 57 | assert 'user.pub' in fns 58 | -------------------------------------------------------------------------------- /src/certsling/tests/test_verify.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | import base64 3 | import pytest 4 | import subprocess 5 | 6 | 7 | @pytest.fixture() 8 | def server_key(base, genkey): 9 | fn = base.joinpath('server.key') 10 | genkey(fn, yesno=lambda m: True) 11 | return fn 12 | 13 | 14 | @pytest.fixture 15 | def gencsr(base, server_key): 16 | from certsling import gencsr 17 | return partial(gencsr, key=server_key) 18 | 19 | 20 | @pytest.fixture() 21 | def ca_key(base, genkey): 22 | fn = base.joinpath('ca.key') 23 | genkey(fn, yesno=lambda m: True) 24 | return fn 25 | 26 | 27 | @pytest.fixture(params=[ 28 | 'Foo', 29 | 'happy hacker fake CA', 30 | 'Fake LE Intermediate X1', 31 | "Let's Encrypt Authority X1", 32 | "Let's Encrypt Authority X2", 33 | "Let's Encrypt Authority X3", 34 | "Let's Encrypt Authority X4"]) 35 | def ca_crt(base, ca_key, request): 36 | fn = base.joinpath('ca.crt') 37 | subprocess.check_call([ 38 | 'openssl', 'req', 39 | '-new', '-x509', '-key', str(ca_key), '-out', str(fn), 40 | '-subj', '/C=DE/CN=%s' % request.param, 41 | '-days', '90']) 42 | return fn 43 | 44 | 45 | @pytest.fixture() 46 | def signer(base, ca_key, ca_crt): 47 | from certsling import createSubjectAltName 48 | 49 | def signer(csr, crt, domains): 50 | import textwrap 51 | conf = base.joinpath('ca.conf') 52 | conf.open('w').write(textwrap.dedent("""\ 53 | extensions = extend 54 | [extend] # openssl extensions 55 | %s 56 | """ % createSubjectAltName(domains))) 57 | subprocess.check_call([ 58 | 'openssl', 'x509', '-extfile', str(conf), 59 | '-req', '-CA', str(ca_crt), '-CAkey', str(ca_key), 60 | '-set_serial', '01', '-in', str(csr), '-out', str(crt), 61 | '-days', '90']) 62 | return signer 63 | 64 | 65 | @pytest.mark.parametrize(['csr_domains', 'verify_domains', 'csr_result', 'crt_result'], [ 66 | ( 67 | ['example.com'], 68 | ['example.com'], 69 | True, True), 70 | ( 71 | ['example.com', 'foo.example.com'], 72 | ['example.com', 'foo.example.com'], 73 | True, True), 74 | ( 75 | ['example.com', 'other.org'], 76 | ['example.com', 'other.org'], 77 | True, True), 78 | ( 79 | ['example.com', 'foo.example.com'], 80 | ['example.com'], 81 | False, False), 82 | ( 83 | ['example.com'], 84 | ['example.com', 'foo.example.com'], 85 | False, False)]) 86 | def test_verify(base, crt_result, csr_domains, csr_result, gencsr, signer, verify_domains): 87 | from certsling import verify_crt, verify_csr 88 | csr_fn = base.joinpath('domain.csr') 89 | gencsr(csr_fn, domains=csr_domains) 90 | csr_content = csr_fn.open('r').read().splitlines() 91 | csr_content = base64.b64decode('\n'.join(csr_content[1:-1])) 92 | for domain in csr_domains: 93 | assert domain.encode('ascii') in csr_content 94 | assert verify_csr(csr_fn, domains=verify_domains) is csr_result 95 | crt_fn = base.joinpath('domain.crt') 96 | signer(str(csr_fn), str(crt_fn), csr_domains) 97 | crt_content = crt_fn.open('r').read().splitlines() 98 | crt_content = base64.b64decode('\n'.join(crt_content[1:-1])) 99 | for domain in csr_domains: 100 | assert domain.encode('ascii') in crt_content 101 | assert verify_crt(crt_fn, domains=verify_domains) is crt_result 102 | -------------------------------------------------------------------------------- /src/certsling/utils.py: -------------------------------------------------------------------------------- 1 | import click 2 | import json 3 | import sys 4 | 5 | 6 | def fatal(msg, code=3): 7 | click.echo(click.style(msg, fg='red')) 8 | sys.exit(code) 9 | 10 | 11 | def fatal_response(msg, response, code=3): 12 | try: 13 | data = response.json() 14 | except ValueError: 15 | data = {} 16 | headers = '\n'.join(': '.join(x) for x in response.headers.items()) 17 | fatal("%s: %s %s\n%s\n%s" % ( 18 | msg, 19 | response.status_code, response.reason, 20 | json.dumps(data, sort_keys=True, indent=4), 21 | headers)) 22 | 23 | 24 | def ensure_not_empty(fn): 25 | if fn.exists(): 26 | with fn.open('rb') as f: 27 | l = len(f.read().strip()) 28 | if l: 29 | return True 30 | fn.unlink() 31 | return False 32 | 33 | 34 | def _file_generator(base, name, update=False): 35 | def generator(description, ext, generate, *args, **kw): 36 | fn = base.joinpath("%s%s" % (name, ext)) 37 | rel = fn.relative_to(base) 38 | if not update and ensure_not_empty(fn): 39 | click.echo(click.style( 40 | "Using existing %s '%s'." % (description, rel), fg='green')) 41 | return fn 42 | click.echo("Writing %s '%s'." % (description, rel)) 43 | generate(fn, *args, **kw) 44 | return fn 45 | return generator 46 | 47 | 48 | def _dated_file_generator(base, name, date, current=False): 49 | def generator(description, ext, generate, *args, **kw): 50 | fn = base.joinpath("%s%s" % (name, ext)) 51 | rel = fn.relative_to(base) 52 | fn_date = base.joinpath("%s-%s%s" % (name, date, ext)) 53 | rel_date = fn_date.relative_to(base) 54 | if current == 'force' and fn.exists(): 55 | click.echo("Unlinking existing %s '%s'." % (description, rel)) 56 | fn.unlink() 57 | if ensure_not_empty(fn): 58 | if not current or fn.resolve() == fn_date: 59 | click.echo(click.style( 60 | "Using existing %s '%s'." % (description, rel), fg='green')) 61 | return fn 62 | elif fn.exists(): 63 | click.echo("Unlinking existing %s '%s'." % (description, rel)) 64 | fn.unlink() 65 | if not ensure_not_empty(fn): 66 | click.echo("Generating %s '%s'." % (description, rel_date)) 67 | generate(fn_date, *args, **kw) 68 | if fn_date.exists(): 69 | click.echo("Linking %s '%s'." % (description, rel_date)) 70 | fn.symlink_to(fn_date.name) 71 | return fn 72 | return generator 73 | 74 | 75 | def yesno(question, default=None, all=False, always_yes=False): 76 | if default is True: 77 | question = "%s [Yes/no" % question 78 | answers = { 79 | False: ('n', 'no'), 80 | True: ('', 'y', 'yes'), 81 | } 82 | elif default is False: 83 | question = "%s [yes/No" % question 84 | answers = { 85 | False: ('', 'n', 'no'), 86 | True: ('y', 'yes'), 87 | } 88 | else: 89 | question = "%s [yes/no" % question 90 | answers = { 91 | False: ('n', 'no'), 92 | True: ('y', 'yes'), 93 | } 94 | if all: 95 | if default == 'all': 96 | answers['all'] = ('', 'a', 'all') 97 | question = "%s/All" % question 98 | else: 99 | answers['all'] = ('a', 'all') 100 | question = "%s/all" % question 101 | question = "%s] " % question 102 | while 1: 103 | if default is None and always_yes: 104 | answer = 'yes' 105 | else: 106 | answer = input(question).lower() 107 | for option in answers: 108 | if answer in answers[option]: 109 | return option 110 | if all: 111 | click.echo("You have to answer with y, yes, n, no, a or all.") 112 | else: 113 | click.echo("You have to answer with y, yes, n or no.") 114 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist=py37,py38,py39,py310 3 | 4 | [testenv] 5 | commands = py.test --flake8 --cov {envsitepackagesdir}/certsling --cov src/certsling/tests --cov-report html:cov-{envname} --cov-report term {posargs} 6 | deps = 7 | pytest 8 | pytest-cov 9 | pytest-flake8 10 | flake8<4 11 | 12 | [pytest] 13 | testpaths = src/certsling 14 | flake8-ignore = E501 E741 15 | --------------------------------------------------------------------------------