From 406dbfe689f83386a8933ffc0db7311baa47d620 Mon Sep 17 00:00:00 2001 From: Samuele Santi Date: Mon, 30 May 2016 03:07:52 +0100 Subject: [PATCH 1/6] New unified script to generate + sign certificates --- letsencrypt_nosudo.py | 761 ++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 2 + 2 files changed, 763 insertions(+) create mode 100755 letsencrypt_nosudo.py create mode 100644 requirements.txt diff --git a/letsencrypt_nosudo.py b/letsencrypt_nosudo.py new file mode 100755 index 0000000..df75020 --- /dev/null +++ b/letsencrypt_nosudo.py @@ -0,0 +1,761 @@ +#!/usr/bin/env python2.7 + +import base64 +import binascii +import copy +import hashlib +import json +import logging +import os +import random +import re +import subprocess +import sys +import tempfile +import textwrap +import time +from BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer +from contextlib import contextmanager +from multiprocessing import Process + +import click +import requests + +logger = logging.getLogger('letsencrypt_nosudo') +STAGING_CA = "https://acme-staging.api.letsencrypt.org" +PRODUCTION_CA = "https://acme-v01.api.letsencrypt.org" +TERMS = "https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf" +CA_CERT_URLS = [ + 'https://letsencrypt.org/certs/lets-encrypt-x1-cross-signed.pem', + 'https://letsencrypt.org/certs/lets-encrypt-x2-cross-signed.pem', + 'https://letsencrypt.org/certs/lets-encrypt-x3-cross-signed.pem', + 'https://letsencrypt.org/certs/lets-encrypt-x4-cross-signed.pem', +] + + +@click.group() +def main(): + pass + + +@main.command(help='Generate user key pair') +@click.argument('name') +def generate_user_key(name): + if name.endswith(('.key', '.pub')): + name = name[:-4] + + privkey_filename = '{}.key'.format(name) + pubkey_filename = '{}.pub'.format(name) + + if os.path.exists(privkey_filename): + logger.critical('Private key file {} already exists. Not overwriting.' + .format(privkey_filename)) + raise click.Abort() + + if os.path.exists(pubkey_filename): + logger.warning('Public key file {} already exists. Not overwriting.' + .format(pubkey_filename)) + raise click.Abort() + + logger.info('Generating private key: {}'.format(privkey_filename)) + with open(privkey_filename, 'w') as fp: + subprocess.check_call( + ('openssl', 'genrsa', '4096'), stdout=fp) + + logger.info('Exporting public key: {}'.format(pubkey_filename)) + with open(pubkey_filename, 'w') as fp: + subprocess.check_call( + ('openssl', 'rsa', '-in', privkey_filename, '-pubout'), stdout=fp) + + +@main.command(help='Generate private key for a domain') +@click.argument('name') +def generate_domain_key(name): + if name.endswith(('.key', '.pub')): + name = name[:-4] + + privkey_filename = '{}.key'.format(name) + + if os.path.exists(privkey_filename): + logger.critical('Private key file {} already exists. Not overwriting.' + .format(privkey_filename)) + raise click.Abort() + + logger.info('Generating private key: {}'.format(privkey_filename)) + with open(privkey_filename, 'w') as fp: + subprocess.check_call( + ('openssl', 'genrsa', '4096'), stdout=fp) + + +@main.command(help='Generate certificate signing request for a domain') +@click.option('-d', '--domain-name', 'domain_names', multiple=True) +@click.option('-k', '--domain-key') +@click.option('-o', '--output') +@click.option('--base-openssl-config', default='/etc/ssl/openssl.cnf') +def generate_csr(domain_names, domain_key, base_openssl_config, output): + if len(domain_names) < 1: + logger.critical('You must pass at least one domain name') + raise click.Abort() + + if domain_key is None: + domain_key = '{}.key'.format(domain_names[0]) + logger.info('Domain key not specified. Try guessing {}' + .format(domain_key)) + + if not os.path.exists(domain_key): + logger.critical('Domain key not found: {}'.format(domain_key)) + raise click.Abort() + + if output is None: + output = '{}.csr'.format(domain_names[0]) + logger.info('Creating CSR for {} domains: {}' + .format(len(domain_names), output)) + + if os.path.exists(output): + if not click.confirm('File exists. Overwrite?', default=False): + raise click.Abort() + + with open(base_openssl_config) as fp: + openssl_config = fp.read() + + subj_alt_name = ','.join('DNS:{}'.format(x) for x in domain_names) + + with tempfile.NamedTemporaryFile(suffix='.cnf') as cfgfile: + cfgfile.write(openssl_config) + cfgfile.write('\n[SAN]\n') + cfgfile.write('subjectAltName={}\n'.format(subj_alt_name)) + cfgfile.seek(0) + + command = [ + 'openssl', 'req', '-new', '-sha256', '-key', domain_key, + '-subj', '/', '-reqexts', 'SAN', '-config', cfgfile.name, + ] + + with open(output, 'w') as fp: + subprocess.check_call(command, stdout=fp) + + +@main.command(help='Sign a CSR via letsencrypt') +@click.option('-k', '--public-key', default='user.pub', metavar='PATH') +@click.option('-K', '--private-key', default='user.key', metavar='PATH') +@click.option('--email', default='Contact email', prompt='Contact email') +@click.option('--ca-url', default='production', + help='URL to the CA API, or "production" (default) / "staging"') +@click.option('-m', '--method', + type=click.Choice(['file', 'run-manual', 'run-local'])) +@click.option('-p', '--port', default=80, type=int) +@click.option('-f', '--input-file', help='Path to the CSR to be signed') +@click.option('-o', '--output', help='Certificate file name') +def sign_csr(public_key, private_key, email, ca_url, method, port, input_file, + output): + + if ca_url == 'production': + ca_url = PRODUCTION_CA + elif ca_url == 'staging': + ca_url = STAGING_CA + + if input_file is None: + # TODO guess + logger.critical('An input file is required') + raise click.Abort() + + if output is None: + # TODO generate + logger.critical('An output file is required') + raise click.Abort() + + # ------------------------------------------------------------ + + pubkey_info = _read_pubkey_file_info(public_key) + csr_info = _read_csr_file_info(input_file) + + if email is None: + raise NotImplementedError('TODO generate email from domain') + + client = LetsencryptClient(ca_url) + + # ------------------------------------------------------------ + # Step 4: Generate the payloads that need to be signed + # registration + + logger.info("Building request payloads") + reg_nonce = client.get_nonce() + reg_raw = json.dumps({ + "resource": "new-reg", + "contact": ["mailto:{0}".format(email)], + "agreement": TERMS, + }) + reg_b64 = _b64(reg_raw) + + reg_protected = copy.deepcopy(pubkey_info.header) + reg_protected['nonce'] = reg_nonce + reg_protected64 = _b64(json.dumps(reg_protected)) + + reg_file = tempfile.NamedTemporaryFile( + dir=".", prefix="register_", suffix=".json") + reg_file.write("{0}.{1}".format(reg_protected64, reg_b64)) + reg_file.flush() + reg_file_name = os.path.basename(reg_file.name) + + reg_file_sig = tempfile.NamedTemporaryFile( + dir=".", prefix="register_", suffix=".sig") + reg_file_sig_name = os.path.basename(reg_file_sig.name) + + # need signature for each domain identifiers + ids = [] + for domain in csr_info.domain_names: + logger.info("Building request for %s", domain) + id_nonce = client.get_nonce() + id_raw = json.dumps({ + "resource": "new-authz", + "identifier": { + "type": "dns", + "value": domain, + }, + }) + id_b64 = _b64(id_raw) + id_protected = copy.deepcopy(pubkey_info.header) + id_protected['nonce'] = id_nonce + id_protected64 = _b64(json.dumps(id_protected)) + id_file = tempfile.NamedTemporaryFile( + dir=".", prefix="domain_", suffix=".json") + id_file.write("{0}.{1}".format(id_protected64, id_b64)) + id_file.flush() + id_file_name = os.path.basename(id_file.name) + id_file_sig = tempfile.NamedTemporaryFile( + dir=".", prefix="domain_", suffix=".sig") + id_file_sig_name = os.path.basename(id_file_sig.name) + ids.append({ + "domain": domain, + "protected64": id_protected64, + "data64": id_b64, + "file": id_file, + "file_name": id_file_name, + "sig": id_file_sig, + "sig_name": id_file_sig_name, + }) + + # need signature for the final certificate issuance + logger.info("Building request for CSR") + csr_der = subprocess.check_output( + ["openssl", "req", "-in", input_file, "-outform", "DER"]) + csr_der64 = _b64(csr_der) + csr_nonce = client.get_nonce() + csr_raw = json.dumps({ + "resource": "new-cert", + "csr": csr_der64, + }, sort_keys=True, indent=4) + csr_b64 = _b64(csr_raw) + csr_protected = copy.deepcopy(pubkey_info.header) + csr_protected.update({"nonce": csr_nonce}) + csr_protected64 = _b64(json.dumps(csr_protected, sort_keys=True, indent=4)) + csr_file = tempfile.NamedTemporaryFile( + dir=".", prefix="cert_", suffix=".json") + csr_file.write("{0}.{1}".format(csr_protected64, csr_b64)) + csr_file.flush() + csr_file_name = os.path.basename(csr_file.name) + csr_file_sig = tempfile.NamedTemporaryFile( + dir=".", prefix="cert_", suffix=".sig") + csr_file_sig_name = os.path.basename(csr_file_sig.name) + + # ---------------------------------------------------------------------- + # Step 5: Ask the user to sign the registration and requests + logger.info('Signing registration and requests') + SIGN_COMMAND = 'openssl dgst -sha256 -sign user.key -out {} {}' + + _openssl_sign(private_key, reg_file_sig_name, reg_file_name) + for i in ids: + _openssl_sign(private_key, i['sig_name'], i['file_name']) + _openssl_sign(private_key, csr_file_sig_name, csr_file_name) + + # ---------------------------------------------------------------------- + # Step 6: Load the signatures + + reg_file_sig.seek(0) + reg_sig64 = _b64(reg_file_sig.read()) + for n, i in enumerate(ids): + i['sig'].seek(0) + i['sig64'] = _b64(i['sig'].read()) + + # ---------------------------------------------------------------------- + # Step 7: Register the user + + logger.info("Registering user: %s", email) + try: + client.register_user( + key_header=pubkey_info.header, + protected_b64=reg_protected64, + payload_b64=reg_b64, + sig_b64=reg_sig64) + except LetsencryptClientError as exc: + content = exc.response.content + if "Registration key is already in use" in content: + # TODO: check status_code as well? + logger.warning("User is already registered. Skipping.") + else: + raise + + # ---------------------------------------------------------------------- + # Step 8: Request challenges for each domain + + responses = [] + tests = [] + for n, i in enumerate(ids): + logger.info("Requesting challenges for %s [%s -> %s]", + i['domain'], n, i) + + result = client.new_authz( + key_header=pubkey_info.header, + protected_b64=i['protected64'], + payload_b64=i['data64'], + sig_b64=i['sig64']) + challenge = [c for c in result['challenges'] + if c['type'] == "http-01"][0] + keyauthorization = "{0}.{1}".format(challenge['token'], + pubkey_info.thumbprint) + + # challenge request + logger.info("Building challenge responses for %s", i['domain']) + test_nonce = client.get_nonce() + test_raw = json.dumps({ + "resource": "challenge", + "keyAuthorization": keyauthorization, + }, sort_keys=True, indent=4) + test_b64 = _b64(test_raw) + test_protected = copy.deepcopy(pubkey_info.header) + test_protected.update({"nonce": test_nonce}) + test_protected64 = _b64(json.dumps(test_protected)) + test_file = tempfile.NamedTemporaryFile( + dir=".", prefix="challenge_", suffix=".json") + test_file.write("{0}.{1}".format(test_protected64, test_b64)) + test_file.flush() + test_file_name = os.path.basename(test_file.name) + test_file_sig = tempfile.NamedTemporaryFile( + dir=".", prefix="challenge_", suffix=".sig") + test_file_sig_name = os.path.basename(test_file_sig.name) + tests.append({ + "uri": challenge['uri'], + "protected64": test_protected64, + "data64": test_b64, + "file": test_file, + "file_name": test_file_name, + "sig": test_file_sig, + "sig_name": test_file_sig_name, + }) + + # challenge response for server + responses.append({ + "uri": ".well-known/acme-challenge/{0}".format(challenge['token']), + "data": keyauthorization, + }) + + # ---------------------------------------------------------------------- + # Step 9: Ask the user to sign the challenge responses + for i in tests: + _openssl_sign(private_key, i['sig_name'], i['file_name']) + + # ---------------------------------------------------------------------- + # Step 10: Load the response signatures + for n, i in enumerate(ids): + tests[n]['sig'].seek(0) + tests[n]['sig64'] = _b64(tests[n]['sig'].read()) + + # ---------------------------------------------------------------------- + # Step 11: Ask the user to host the token on their server + + for n, i in enumerate(ids): + response = responses[n] + verify_context = _get_verification_ctx( + method, idx=n, domain=i['domain'], path=response['uri'], + data=response['data'], port=port) + + with verify_context: + + # -------------------------------------------------------------- + # Step 12: Let the CA know you're ready for the challenge + logger.info("Requesting verification for %s", i['domain']) + test_url = tests[n]['uri'] + + result = client.request_verification( + url=test_url, + key_header=pubkey_info.header, + protected_b64=tests[n]['protected64'], + payload_b64=tests[n]['data64'], + signature_b64=tests[n]['sig64']) + + # -------------------------------------------------------------- + # Step 13: Wait for CA to mark test as valid + logger.info("Waiting for %s challenge to pass", i['domain']) + + while True: + logger.debug('Polling status at %s', test_url) + challenge_status = client.get_challenge_status(test_url) + + if challenge_status == "pending": + time.sleep(2) + + elif challenge_status == "valid": + logger.info("Passed challenge for %s", i['domain']) + break + + else: + logger.critical('Challenge failed for %s (status: %s)', + i['domain'], challenge_status) + raise click.Abort() + + # ---------------------------------------------------------------------- + # Step 14: Get the certificate signed + logger.info('Requesting signature') + csr_file_sig.seek(0) + csr_sig64 = _b64(csr_file_sig.read()) + signed_der = client.new_cert( + key_header=pubkey_info.header, + protected_b64=csr_protected64, + payload_b64=csr_b64, + sig_b64=csr_sig64) + + # ---------------------------------------------------------------------- + # Step 15: Convert the signed cert from DER to PEM + logger.info("Certificate signed successfully") + + signed_der64 = base64.b64encode(signed_der) + signed_pem = ( + '-----BEGIN CERTIFICATE-----\n' + '{}\n' + '-----END CERTIFICATE-----\n' + .format("\n".join(textwrap.wrap(signed_der64, 64)))) + + logger.info('Writing signed certificate to %s', output) + with open(output, 'w') as fp: + fp.write(signed_pem) + + # ---------------------------------------------------------------------- + # Step 16: generate full PEM w/ chained certs (for NGINX) + output_chained = output + '.pem' + logger.info('Generating chained certificate %s', output_chained) + url = random.choice(CA_CERT_URLS) + logger.info('Chaining with %s', url) + + resp = requests.get(url) + if not resp.ok: + logger.critical('Getting CA certificate %s failed', url) + raise click.Abort() + cert_data = resp.content + + with open(output_chained, 'w') as fp: + fp.write(signed_pem) + fp.write('\n') + fp.write(cert_data) + logger.info('Chained certificate written to %s', output_chained) + + +class SimpleDataStructure(object): + def __init__(self, **kw): + self.__dict__.update(kw) + + +def _read_pubkey_file_info(filename): + logger.info('Reading pubkey file {} ...'.format(filename)) + cmd_output = subprocess.check_output( + ('openssl', 'rsa', '-pubin', '-in', filename, '-noout', '-text')) + + pub_hex, pub_exp = re.search( + r"Modulus(?: \((?:2048|4096) bit\)|)\:\s+00:([a-f0-9\:\s]+?)" + r"Exponent\: ([0-9]+)", cmd_output, re.MULTILINE | re.DOTALL).groups() + pub_mod = binascii.unhexlify(re.sub("(\s|:)", "", pub_hex)) + pub_mod64 = _b64(pub_mod) + pub_exp = int(pub_exp) + pub_exp = "{0:x}".format(pub_exp) + pub_exp = "0{0}".format(pub_exp) if len(pub_exp) % 2 else pub_exp + pub_exp = binascii.unhexlify(pub_exp) + pub_exp64 = _b64(pub_exp) + header = { + "alg": "RS256", + "jwk": { + "e": pub_exp64, + "kty": "RSA", + "n": pub_mod64, + }, + } + accountkey_json = json.dumps( + header['jwk'], sort_keys=True, separators=(',', ':')) + thumbprint = _b64(hashlib.sha256(accountkey_json).digest()) + + return PubkeyFileInfo( + # TODO: which information is required outside? + header=header, + thumbprint=thumbprint, + ) + + +class PubkeyFileInfo(SimpleDataStructure): + pass + + +def _read_csr_file_info(filename): + logger.info("Reading CSR file {} ...".format(filename)) + cmd_output = subprocess.check_output( + ["openssl", "req", "-in", filename, "-noout", "-text"]) + + def _find_common_name(): + match = re.search(r"Subject:.*? CN=([^\s,;/]+)", cmd_output) + if match is not None: + return match.group(1) + return None + + def _find_alt_names(): + match = re.search( + r"X509v3 Subject Alternative Name: \n +([^\n]+)\n", cmd_output, + re.MULTILINE | re.DOTALL) + if match is not None: + for san in match.group(1).split(", "): + if san.startswith("DNS:"): + yield san[4:] + + info = CSRFileInfo() + + info.common_name = _find_common_name() + logger.info("Common name: %s", info.common_name) + + info.alt_names = list(_find_alt_names()) + logger.info("Alt names: %s", ", ".join(info.alt_names)) + + info.domain_names = set() + info.domain_names.add(info.common_name) + info.domain_names.update(info.alt_names) + info.domain_names.discard(None) + logger.info("All domain names: %s", ", ".join(info.domain_names)) + + return info + + +class CSRFileInfo(SimpleDataStructure): + pass + + +class LetsencryptClient(object): + + def __init__(self, base_url): + self.base_url = base_url + + def get_nonce(self): + url = '{}/directory'.format(self.base_url) + response = self._request('HEAD', url) + return response.headers['Replay-Nonce'] + + def register_user(self, key_header, protected_b64, payload_b64, sig_b64): + + url = "{}/acme/new-reg".format(self.base_url) + data = { + "header": key_header, + "protected": protected_b64, + "payload": payload_b64, + "signature": sig_b64, + } + response = self._request('POST', url, json=data) + return response.json() + + def new_authz(self, key_header, protected_b64, payload_b64, sig_b64): + url = "{}/acme/new-authz".format(self.base_url) + data = { + "header": key_header, + "protected": protected_b64, + "payload": payload_b64, + "signature": sig_b64, + } + response = self._request('POST', url, json=data) + return response.json() + + def request_verification( + self, url, key_header, protected_b64, payload_b64, signature_b64): + data = { + "header": key_header, + "protected": protected_b64, + "payload": payload_b64, + "signature": signature_b64, + } + response = self._request('POST', url, json=data) + return response.json() + + def get_challenge_status(self, url): + response = self._request('GET', url) + return response.json()['status'] + + def new_cert(self, key_header, protected_b64, payload_b64, sig_b64): + url = "{}/acme/new-cert".format(self.base_url) + data = { + "header": key_header, + "protected": protected_b64, + "payload": payload_b64, + "signature": sig_b64, + } + response = self._request('POST', url, json=data) + return response.content + + def _request(self, method, url, *a, **kw): + response = requests.request(method, url, *a, **kw) + if not response.ok: + raise LetsencryptClientError( + 'HTTP Error {}: {}' + .format(response.status_code, response.content[:200]), + response=response) + return response + + +class LetsencryptClientError(Exception): + def __init__(self, *a, **kw): + response = kw.pop('response') + super(LetsencryptClientError, self).__init__(*a, **kw) + self.response = response + + +def _b64(b): + "Shortcut function to go from bytes to jwt base64 string" + return base64.urlsafe_b64encode(b).replace("=", "") + + +def _openssl_sign(privkey, outfile, infile): + command = ('openssl', 'dgst', '-sha256', '-sign', privkey, + '-out', outfile, infile) + logger.info('Signing command: %s', str(command)) + subprocess.check_call(command) + + +def _get_verification_ctx(method, idx, domain, path, data, port): + if method == 'file': + return _verification_file_based_ctx(idx, domain, path, data) + if method == 'run-manual': + return _verification_run_remote_ctx(idx, domain, data, port) + if method == 'run-local': + return _verification_run_local_ctx(idx, domain, path, data, port) + raise AssertionError + + +@contextmanager +def _verification_file_based_ctx(idx, domain, path, data): + click.echo( + 'STEP 4.{idx}: Please update your server to serve the following ' + 'file at this URL:\n\n' + + '--------------\n' + 'URL: http://{domain}/{path}\n' + 'File contents: \"{contents}\"\n' + '--------------\n\n' + + 'Notes:\n' + '- Do not include the quotes in the file.\n' + '- The file should be one line without any spaces.\n' + + .format(idx=idx, domain=domain, path=path, data=data), err=True) + click.prompt('Press ENTER to continue', err=True) + yield + click.echo("You can now remove the acme-challenge file " + "from your webserver", err=True) + + +@contextmanager +def _verification_run_remote_ctx(idx, domain, data, port): + click.echo( + 'STEP 4.{idx}: You need to run this command on {domain} ' + '(don\'t stop the python command until the next step).\n' + + 'sudo python -c "import BaseHTTPServer; \\\n' + ' h = BaseHTTPServer.BaseHTTPRequestHandler; \\\n' + ' h.do_GET = lambda r: r.send_response(200) or r.end_headers() ' + 'or r.wfile.write(\'{data}\'); \\\n' + ' s = BaseHTTPServer.HTTPServer((\'0.0.0.0\', {port}), h); \\\n' + ' s.serve_forever()"\n' + + .format(idx=idx, domain=domain, data=data, port=port), err=True) + click.prompt('Press ENTER to continue', err=True) + yield + click.echo('You can stop running the python command on your server ' + '(Ctrl+C works).', err=True) + + +@contextmanager +def _verification_run_local_ctx(idx, domain, path, data, port): + full_url = 'http://{}/{}'.format(domain, path) + click.echo( + 'Starting HTTP server locally on port {port}\n\n' + + 'To allow the remote server to connect back, use a reverse SSH tunnel ' + 'like this:\n\n' + + ' ssh -R "127.0.0.1:8080:127.0.0.1:{port}" -N {domain}\n\n' + + '(This will listen on connections to port 8080/tcp on the remote ' + 'machine and forward to the process running locally on port {port})\n' + + 'Then, make sure that a GET request to this URL:\n\n' + + ' {url}\n\n' + + 'Returns the expected verification data:\n\n {data}\n' + + .format(idx=idx, domain=domain, port=port, url=full_url, data=data)) + + class MyHttpHandler(BaseHTTPRequestHandler): + def do_GET(r): + r.send_response(200) + r.end_headers() + r.wfile.write(data) + + server = HTTPServer(('0.0.0.0', port), MyHttpHandler) + proc = Process(target=server.serve_forever) + proc.start() + logger.info('Server started with PID %s', proc.pid) + + # Double-check.. + resp = requests.get(full_url) + if not resp.ok: + logger.warning('A request to %s returned status code %s', + full_url, resp.status_code) + if resp.content != data: + logger.warning('A request to %s returned a mismatching response', + full_url) + logger.info('All good, %s is responding properly', full_url) + + yield + + logger.info('Waiting for server process %s to terminate', proc.pid) + proc.terminate() + proc.join() + + +def setup_logging(): + handler = logging.StreamHandler(sys.stderr) + handler.setLevel(logging.INFO) + + class MyFormatter(logging.Formatter): + def __init__(self, fmt='[%(name)s] %(message)s', datefmt=None): + super(MyFormatter, self).__init__(fmt, datefmt) + + def format(self, record): + colors = { + logging.DEBUG: 'cyan', + logging.INFO: 'green', + logging.WARNING: 'yellow', + logging.ERROR: 'red', + logging.CRITICAL: 'red', + } + + color = colors.get(record.levelno) + levelname = click.style(record.levelname, fg=color, bold=True) + message = super(MyFormatter, self).format(record) + if record.name.split('.')[0] == 'letsencrypt_nosudo': + message = click.style(message, fg=color) + else: + message = click.style(message, fg='white') + return '{} {}'.format(levelname, message) + + handler.setFormatter(MyFormatter()) + + root_logger = logging.getLogger() + root_logger.addHandler(handler) + root_logger.setLevel(logging.INFO) + + +if __name__ == '__main__': + setup_logging() + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0d8c96e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +click +requests From 82f4789de2815aed853ede06ced4e35f5b50a5d8 Mon Sep 17 00:00:00 2001 From: Samuele Santi Date: Mon, 30 May 2016 03:56:49 +0100 Subject: [PATCH 2/6] Upd gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index db4561e..cd4120a 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,6 @@ docs/_build/ # PyBuilder target/ + +.venv* +*~ From 46a05ecaca0d5d4a5b9abef9b6f689c44d22f28a Mon Sep 17 00:00:00 2001 From: Samuele Santi Date: Mon, 30 May 2016 17:44:32 +0100 Subject: [PATCH 3/6] Always concatenate X3 authority cert --- letsencrypt_nosudo.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/letsencrypt_nosudo.py b/letsencrypt_nosudo.py index df75020..0329c87 100755 --- a/letsencrypt_nosudo.py +++ b/letsencrypt_nosudo.py @@ -26,10 +26,10 @@ PRODUCTION_CA = "https://acme-v01.api.letsencrypt.org" TERMS = "https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf" CA_CERT_URLS = [ - 'https://letsencrypt.org/certs/lets-encrypt-x1-cross-signed.pem', - 'https://letsencrypt.org/certs/lets-encrypt-x2-cross-signed.pem', + # 'https://letsencrypt.org/certs/lets-encrypt-x1-cross-signed.pem', + # 'https://letsencrypt.org/certs/lets-encrypt-x2-cross-signed.pem', 'https://letsencrypt.org/certs/lets-encrypt-x3-cross-signed.pem', - 'https://letsencrypt.org/certs/lets-encrypt-x4-cross-signed.pem', + # 'https://letsencrypt.org/certs/lets-encrypt-x4-cross-signed.pem', ] From e67cd7e86be73f49cb40a9b42ed2a22e4c5da21c Mon Sep 17 00:00:00 2001 From: Samuele Santi Date: Mon, 30 May 2016 17:45:15 +0100 Subject: [PATCH 4/6] Remove old sign_csr.py script --- sign_csr.py | 448 ---------------------------------------------------- 1 file changed, 448 deletions(-) delete mode 100644 sign_csr.py diff --git a/sign_csr.py b/sign_csr.py deleted file mode 100644 index ab20a40..0000000 --- a/sign_csr.py +++ /dev/null @@ -1,448 +0,0 @@ -#!/usr/bin/env python -import argparse, subprocess, json, os, urllib2, sys, base64, binascii, time, \ - hashlib, tempfile, re, copy, textwrap - - -def sign_csr(pubkey, csr, email=None, file_based=False): - """Use the ACME protocol to get an ssl certificate signed by a - certificate authority. - - :param string pubkey: Path to the user account public key. - :param string csr: Path to the certificate signing request. - :param string email: An optional user account contact email - (defaults to webmaster@) - :param bool file_based: An optional flag indicating that the - hosting should be file-based rather - than providing a simple python HTTP - server. - - :returns: Signed Certificate (PEM format) - :rtype: string - - """ - #CA = "https://acme-staging.api.letsencrypt.org" - CA = "https://acme-v01.api.letsencrypt.org" - TERMS = "https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf" - nonce_req = urllib2.Request("{0}/directory".format(CA)) - nonce_req.get_method = lambda : 'HEAD' - - def _b64(b): - "Shortcut function to go from bytes to jwt base64 string" - return base64.urlsafe_b64encode(b).replace("=", "") - - # Step 1: Get account public key - sys.stderr.write("Reading pubkey file...\n") - proc = subprocess.Popen(["openssl", "rsa", "-pubin", "-in", pubkey, "-noout", "-text"], - stdout=subprocess.PIPE, stderr=subprocess.PIPE) - out, err = proc.communicate() - if proc.returncode != 0: - raise IOError("Error loading {0}".format(pubkey)) - pub_hex, pub_exp = re.search( - "Modulus(?: \((?:2048|4096) bit\)|)\:\s+00:([a-f0-9\:\s]+?)Exponent\: ([0-9]+)", - out, re.MULTILINE|re.DOTALL).groups() - pub_mod = binascii.unhexlify(re.sub("(\s|:)", "", pub_hex)) - pub_mod64 = _b64(pub_mod) - pub_exp = int(pub_exp) - pub_exp = "{0:x}".format(pub_exp) - pub_exp = "0{0}".format(pub_exp) if len(pub_exp) % 2 else pub_exp - pub_exp = binascii.unhexlify(pub_exp) - pub_exp64 = _b64(pub_exp) - header = { - "alg": "RS256", - "jwk": { - "e": pub_exp64, - "kty": "RSA", - "n": pub_mod64, - }, - } - accountkey_json = json.dumps(header['jwk'], sort_keys=True, separators=(',', ':')) - thumbprint = _b64(hashlib.sha256(accountkey_json).digest()) - sys.stderr.write("Found public key!\n") - - # Step 2: Get the domain names to be certified - sys.stderr.write("Reading csr file...\n") - proc = subprocess.Popen(["openssl", "req", "-in", csr, "-noout", "-text"], - stdout=subprocess.PIPE, stderr=subprocess.PIPE) - out, err = proc.communicate() - if proc.returncode != 0: - raise IOError("Error loading {0}".format(csr)) - domains = set([]) - common_name = re.search("Subject:.*? CN=([^\s,;/]+)", out) - if common_name is not None: - domains.add(common_name.group(1)) - subject_alt_names = re.search("X509v3 Subject Alternative Name: \n +([^\n]+)\n", out, 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:]) - sys.stderr.write("Found domains {0}\n".format(", ".join(domains))) - - # Step 3: Ask user for contact email - if not email: - default_email = "webmaster@{0}".format(min(domains, key=len)) - stdout = sys.stdout - sys.stdout = sys.stderr - input_email = raw_input("STEP 1: What is your contact email? ({0}) ".format(default_email)) - email = input_email if input_email else default_email - sys.stdout = stdout - - # Step 4: Generate the payloads that need to be signed - # registration - sys.stderr.write("Building request payloads...\n") - reg_nonce = urllib2.urlopen(nonce_req).headers['Replay-Nonce'] - reg_raw = json.dumps({ - "resource": "new-reg", - "contact": ["mailto:{0}".format(email)], - "agreement": TERMS, - }, sort_keys=True, indent=4) - reg_b64 = _b64(reg_raw) - reg_protected = copy.deepcopy(header) - reg_protected.update({"nonce": reg_nonce}) - reg_protected64 = _b64(json.dumps(reg_protected, sort_keys=True, indent=4)) - reg_file = tempfile.NamedTemporaryFile(dir=".", prefix="register_", suffix=".json") - reg_file.write("{0}.{1}".format(reg_protected64, reg_b64)) - reg_file.flush() - reg_file_name = os.path.basename(reg_file.name) - reg_file_sig = tempfile.NamedTemporaryFile(dir=".", prefix="register_", suffix=".sig") - reg_file_sig_name = os.path.basename(reg_file_sig.name) - - # need signature for each domain identifiers - ids = [] - for domain in domains: - sys.stderr.write("Building request for {0}...\n".format(domain)) - id_nonce = urllib2.urlopen(nonce_req).headers['Replay-Nonce'] - id_raw = json.dumps({ - "resource": "new-authz", - "identifier": { - "type": "dns", - "value": domain, - }, - }, sort_keys=True) - id_b64 = _b64(id_raw) - id_protected = copy.deepcopy(header) - id_protected.update({"nonce": id_nonce}) - id_protected64 = _b64(json.dumps(id_protected, sort_keys=True, indent=4)) - id_file = tempfile.NamedTemporaryFile(dir=".", prefix="domain_", suffix=".json") - id_file.write("{0}.{1}".format(id_protected64, id_b64)) - id_file.flush() - id_file_name = os.path.basename(id_file.name) - id_file_sig = tempfile.NamedTemporaryFile(dir=".", prefix="domain_", suffix=".sig") - id_file_sig_name = os.path.basename(id_file_sig.name) - ids.append({ - "domain": domain, - "protected64": id_protected64, - "data64": id_b64, - "file": id_file, - "file_name": id_file_name, - "sig": id_file_sig, - "sig_name": id_file_sig_name, - }) - - # need signature for the final certificate issuance - sys.stderr.write("Building request for CSR...\n") - proc = subprocess.Popen(["openssl", "req", "-in", csr, "-outform", "DER"], - stdout=subprocess.PIPE, stderr=subprocess.PIPE) - csr_der, err = proc.communicate() - csr_der64 = _b64(csr_der) - csr_nonce = urllib2.urlopen(nonce_req).headers['Replay-Nonce'] - csr_raw = json.dumps({ - "resource": "new-cert", - "csr": csr_der64, - }, sort_keys=True, indent=4) - csr_b64 = _b64(csr_raw) - csr_protected = copy.deepcopy(header) - csr_protected.update({"nonce": csr_nonce}) - csr_protected64 = _b64(json.dumps(csr_protected, sort_keys=True, indent=4)) - csr_file = tempfile.NamedTemporaryFile(dir=".", prefix="cert_", suffix=".json") - csr_file.write("{0}.{1}".format(csr_protected64, csr_b64)) - csr_file.flush() - csr_file_name = os.path.basename(csr_file.name) - csr_file_sig = tempfile.NamedTemporaryFile(dir=".", prefix="cert_", suffix=".sig") - csr_file_sig_name = os.path.basename(csr_file_sig.name) - - # Step 5: Ask the user to sign the registration and requests - sys.stderr.write("""\ -STEP 2: You need to sign some files (replace 'user.key' with your user private key). - -openssl dgst -sha256 -sign user.key -out {0} {1} -{2} -openssl dgst -sha256 -sign user.key -out {3} {4} - -""".format( - reg_file_sig_name, reg_file_name, - "\n".join("openssl dgst -sha256 -sign user.key -out {0} {1}".format(i['sig_name'], i['file_name']) for i in ids), - csr_file_sig_name, csr_file_name)) - - stdout = sys.stdout - sys.stdout = sys.stderr - raw_input("Press Enter when you've run the above commands in a new terminal window...") - sys.stdout = stdout - - # Step 6: Load the signatures - reg_file_sig.seek(0) - reg_sig64 = _b64(reg_file_sig.read()) - for n, i in enumerate(ids): - i['sig'].seek(0) - i['sig64'] = _b64(i['sig'].read()) - - # Step 7: Register the user - sys.stderr.write("Registering {0}...\n".format(email)) - reg_data = json.dumps({ - "header": header, - "protected": reg_protected64, - "payload": reg_b64, - "signature": reg_sig64, - }, sort_keys=True, indent=4) - reg_url = "{0}/acme/new-reg".format(CA) - try: - resp = urllib2.urlopen(reg_url, reg_data) - result = json.loads(resp.read()) - except urllib2.HTTPError as e: - err = e.read() - # skip already registered accounts - if "Registration key is already in use" in err: - sys.stderr.write("Already registered. Skipping...\n") - else: - sys.stderr.write("Error: reg_data:\n") - sys.stderr.write("POST {0}\n".format(reg_url)) - sys.stderr.write(reg_data) - sys.stderr.write("\n") - sys.stderr.write(err) - sys.stderr.write("\n") - raise - - # Step 8: Request challenges for each domain - responses = [] - tests = [] - for n, i in enumerate(ids): - sys.stderr.write("Requesting challenges for {0}...\n".format(i['domain'])) - id_data = json.dumps({ - "header": header, - "protected": i['protected64'], - "payload": i['data64'], - "signature": i['sig64'], - }, sort_keys=True, indent=4) - id_url = "{0}/acme/new-authz".format(CA) - try: - resp = urllib2.urlopen(id_url, id_data) - result = json.loads(resp.read()) - except urllib2.HTTPError as e: - sys.stderr.write("Error: id_data:\n") - sys.stderr.write("POST {0}\n".format(id_url)) - sys.stderr.write(id_data) - sys.stderr.write("\n") - sys.stderr.write(e.read()) - sys.stderr.write("\n") - raise - challenge = [c for c in result['challenges'] if c['type'] == "http-01"][0] - keyauthorization = "{0}.{1}".format(challenge['token'], thumbprint) - - # challenge request - sys.stderr.write("Building challenge responses for {0}...\n".format(i['domain'])) - test_nonce = urllib2.urlopen(nonce_req).headers['Replay-Nonce'] - test_raw = json.dumps({ - "resource": "challenge", - "keyAuthorization": keyauthorization, - }, sort_keys=True, indent=4) - test_b64 = _b64(test_raw) - test_protected = copy.deepcopy(header) - test_protected.update({"nonce": test_nonce}) - test_protected64 = _b64(json.dumps(test_protected, sort_keys=True, indent=4)) - test_file = tempfile.NamedTemporaryFile(dir=".", prefix="challenge_", suffix=".json") - test_file.write("{0}.{1}".format(test_protected64, test_b64)) - test_file.flush() - test_file_name = os.path.basename(test_file.name) - test_file_sig = tempfile.NamedTemporaryFile(dir=".", prefix="challenge_", suffix=".sig") - test_file_sig_name = os.path.basename(test_file_sig.name) - tests.append({ - "uri": challenge['uri'], - "protected64": test_protected64, - "data64": test_b64, - "file": test_file, - "file_name": test_file_name, - "sig": test_file_sig, - "sig_name": test_file_sig_name, - }) - - # challenge response for server - responses.append({ - "uri": ".well-known/acme-challenge/{0}".format(challenge['token']), - "data": keyauthorization, - }) - - # Step 9: Ask the user to sign the challenge responses - sys.stderr.write("""\ -STEP 3: You need to sign some more files (replace 'user.key' with your user private key). - -{0} - -""".format( - "\n".join("openssl dgst -sha256 -sign user.key -out {0} {1}".format( - i['sig_name'], i['file_name']) for i in tests))) - - stdout = sys.stdout - sys.stdout = sys.stderr - raw_input("Press Enter when you've run the above commands in a new terminal window...") - sys.stdout = stdout - - # Step 10: Load the response signatures - for n, i in enumerate(ids): - tests[n]['sig'].seek(0) - tests[n]['sig64'] = _b64(tests[n]['sig'].read()) - - # Step 11: Ask the user to host the token on their server - for n, i in enumerate(ids): - if file_based: - sys.stderr.write("""\ -STEP {0}: Please update your server to serve the following file at this URL: - --------------- -URL: http://{1}/{2} -File contents: \"{3}\" --------------- - -Notes: -- Do not include the quotes in the file. -- The file should be one line without any spaces. - -""".format(n + 4, i['domain'], responses[n]['uri'], responses[n]['data'])) - - stdout = sys.stdout - sys.stdout = sys.stderr - raw_input("Press Enter when you've got the file hosted on your server...") - sys.stdout = stdout - else: - sys.stderr.write("""\ -STEP {0}: You need to run this command on {1} (don't stop the python command until the next step). - -sudo python -c "import BaseHTTPServer; \\ - h = BaseHTTPServer.BaseHTTPRequestHandler; \\ - h.do_GET = lambda r: r.send_response(200) or r.end_headers() or r.wfile.write('{2}'); \\ - s = BaseHTTPServer.HTTPServer(('0.0.0.0', 80), h); \\ - s.serve_forever()" - -""".format(n + 4, i['domain'], responses[n]['data'])) - - stdout = sys.stdout - sys.stdout = sys.stderr - raw_input("Press Enter when you've got the python command running on your server...") - sys.stdout = stdout - - # Step 12: Let the CA know you're ready for the challenge - sys.stderr.write("Requesting verification for {0}...\n".format(i['domain'])) - test_data = json.dumps({ - "header": header, - "protected": tests[n]['protected64'], - "payload": tests[n]['data64'], - "signature": tests[n]['sig64'], - }, sort_keys=True, indent=4) - test_url = tests[n]['uri'] - try: - resp = urllib2.urlopen(test_url, test_data) - test_result = json.loads(resp.read()) - except urllib2.HTTPError as e: - sys.stderr.write("Error: test_data:\n") - sys.stderr.write("POST {0}\n".format(test_url)) - sys.stderr.write(test_data) - sys.stderr.write("\n") - sys.stderr.write(e.read()) - sys.stderr.write("\n") - raise - - # Step 13: Wait for CA to mark test as valid - sys.stderr.write("Waiting for {0} challenge to pass...\n".format(i['domain'])) - while True: - try: - resp = urllib2.urlopen(test_url) - challenge_status = json.loads(resp.read()) - except urllib2.HTTPError as e: - sys.stderr.write("Error: test_data:\n") - sys.stderr.write("GET {0}\n".format(test_url)) - sys.stderr.write(test_data) - sys.stderr.write("\n") - sys.stderr.write(e.read()) - sys.stderr.write("\n") - raise - if challenge_status['status'] == "pending": - time.sleep(2) - elif challenge_status['status'] == "valid": - sys.stderr.write("Passed {0} challenge!\n".format(i['domain'])) - break - else: - raise KeyError("'{0}' challenge did not pass: {1}".format(i['domain'], challenge_status)) - - # Step 14: Get the certificate signed - sys.stderr.write("Requesting signature...\n") - csr_file_sig.seek(0) - csr_sig64 = _b64(csr_file_sig.read()) - csr_data = json.dumps({ - "header": header, - "protected": csr_protected64, - "payload": csr_b64, - "signature": csr_sig64, - }, sort_keys=True, indent=4) - csr_url = "{0}/acme/new-cert".format(CA) - try: - resp = urllib2.urlopen(csr_url, csr_data) - signed_der = resp.read() - except urllib2.HTTPError as e: - sys.stderr.write("Error: csr_data:\n") - sys.stderr.write("POST {0}\n".format(csr_url)) - sys.stderr.write(csr_data) - sys.stderr.write("\n") - sys.stderr.write(e.read()) - sys.stderr.write("\n") - raise - - # Step 15: Convert the signed cert from DER to PEM - sys.stderr.write("Certificate signed!\n") - - if file_based: - sys.stderr.write("You can remove the acme-challenge file from your webserver now.\n") - else: - sys.stderr.write("You can stop running the python command on your server (Ctrl+C works).\n") - - signed_der64 = base64.b64encode(signed_der) - signed_pem = """\ ------BEGIN CERTIFICATE----- -{0} ------END CERTIFICATE----- -""".format("\n".join(textwrap.wrap(signed_der64, 64))) - - return signed_pem - -if __name__ == "__main__": - parser = argparse.ArgumentParser( - formatter_class=argparse.RawDescriptionHelpFormatter, - description="""\ -Get a SSL certificate signed by a Let's Encrypt (ACME) certificate authority and -output that signed certificate. You do NOT need to run this script on your -server and this script does not ask for your private keys. It will print out -commands that you need to run with your private key or on your server as root, -which gives you a chance to review the commands instead of trusting this script. - -NOTE: YOUR ACCOUNT KEY NEEDS TO BE DIFFERENT FROM YOUR DOMAIN KEY. - -Prerequisites: -* openssl -* python - -Example: Generate an account keypair, a domain key and csr, and have the domain csr signed. --------------- -$ openssl genrsa 4096 > user.key -$ openssl rsa -in user.key -pubout > user.pub -$ openssl genrsa 4096 > domain.key -$ openssl req -new -sha256 -key domain.key -subj "/CN=example.com" > domain.csr -$ python sign_csr.py --public-key user.pub domain.csr > signed.crt --------------- - -""") - parser.add_argument("-p", "--public-key", required=True, help="path to your account public key") - parser.add_argument("-e", "--email", default=None, help="contact email, default is webmaster@") - parser.add_argument("-f", "--file-based", action='store_true', help="if set, a file-based response is used") - parser.add_argument("csr_path", help="path to your certificate signing request") - - args = parser.parse_args() - signed_crt = sign_csr(args.public_key, args.csr_path, email=args.email, file_based=args.file_based) - sys.stdout.write(signed_crt) - From 936bfa65c0b2ac957c1d18fbe8c6091e1fd8719d Mon Sep 17 00:00:00 2001 From: Samuele Santi Date: Mon, 30 May 2016 18:38:04 +0100 Subject: [PATCH 5/6] Just a few tweaks --- letsencrypt_nosudo.py | 123 +++++++++++++++++++++++++++++------------- 1 file changed, 87 insertions(+), 36 deletions(-) diff --git a/letsencrypt_nosudo.py b/letsencrypt_nosudo.py index 0329c87..b570747 100755 --- a/letsencrypt_nosudo.py +++ b/letsencrypt_nosudo.py @@ -41,9 +41,7 @@ def main(): @main.command(help='Generate user key pair') @click.argument('name') def generate_user_key(name): - if name.endswith(('.key', '.pub')): - name = name[:-4] - + name = _strip_exts(name, ('.key', '.pub')) privkey_filename = '{}.key'.format(name) pubkey_filename = '{}.pub'.format(name) @@ -71,9 +69,7 @@ def generate_user_key(name): @main.command(help='Generate private key for a domain') @click.argument('name') def generate_domain_key(name): - if name.endswith(('.key', '.pub')): - name = name[:-4] - + name = _strip_exts(name, ('.key', '.pub')) privkey_filename = '{}.key'.format(name) if os.path.exists(privkey_filename): @@ -138,16 +134,29 @@ def generate_csr(domain_names, domain_key, base_openssl_config, output): @main.command(help='Sign a CSR via letsencrypt') @click.option('-k', '--public-key', default='user.pub', metavar='PATH') @click.option('-K', '--private-key', default='user.key', metavar='PATH') -@click.option('--email', default='Contact email', prompt='Contact email') +@click.option('--email') @click.option('--ca-url', default='production', help='URL to the CA API, or "production" (default) / "staging"') @click.option('-m', '--method', type=click.Choice(['file', 'run-manual', 'run-local'])) @click.option('-p', '--port', default=80, type=int) +@click.option('-b', '--base-name', metavar='PATH', + help='Base name for CSR/CRT/PEM files') @click.option('-f', '--input-file', help='Path to the CSR to be signed') @click.option('-o', '--output', help='Certificate file name') -def sign_csr(public_key, private_key, email, ca_url, method, port, input_file, - output): +@click.option('--chained-output', help='Name for the output file') +def sign_csr(public_key, private_key, email, ca_url, method, port, + base_name, input_file, output, chained_output): + + if (not public_key) and private_key: + public_key = _replace_ext(private_key, '.pub', ('.key',)) + + if (not private_key) and public_key: + private_key = _replace_ext(public_key, '.key', ('.pub',)) + + if not (private_key and public_key): + logger.critical('A user key pair is required!') + raise click.Abort() if ca_url == 'production': ca_url = PRODUCTION_CA @@ -155,14 +164,27 @@ def sign_csr(public_key, private_key, email, ca_url, method, port, input_file, ca_url = STAGING_CA if input_file is None: - # TODO guess - logger.critical('An input file is required') - raise click.Abort() + if base_name: + input_file = base_name + '.csr' + else: + logger.critical('An input file is required') + raise click.Abort() if output is None: - # TODO generate - logger.critical('An output file is required') - raise click.Abort() + if base_name: + output = base_name + '.crt' + else: + logger.critical('An output file is required') + raise click.Abort() + + if chained_output is None: + if base_name: + chained_output = base_name + '.pem' + elif output: + chained_output = _replace_ext(output, '.pem', ('.crt',)) + else: + logger.critical('Unable to determine name for PEM certificate') + raise click.Abort() # ------------------------------------------------------------ @@ -170,7 +192,12 @@ def sign_csr(public_key, private_key, email, ca_url, method, port, input_file, csr_info = _read_csr_file_info(input_file) if email is None: - raise NotImplementedError('TODO generate email from domain') + default_email = 'webmaster@example.com' + if csr_info.common_name: + default_email = 'webmaster@{}'.format(csr_info.common_name) + elif len(csr_info.alt_names): + default_email = 'webmaster@{}'.format(csr_info.alt_names[0]) + email = click.prompt('Contact email', default=default_email) client = LetsencryptClient(ca_url) @@ -417,13 +444,7 @@ def sign_csr(public_key, private_key, email, ca_url, method, port, input_file, # ---------------------------------------------------------------------- # Step 15: Convert the signed cert from DER to PEM logger.info("Certificate signed successfully") - - signed_der64 = base64.b64encode(signed_der) - signed_pem = ( - '-----BEGIN CERTIFICATE-----\n' - '{}\n' - '-----END CERTIFICATE-----\n' - .format("\n".join(textwrap.wrap(signed_der64, 64)))) + signed_pem = _export_certificate(signed_der) logger.info('Writing signed certificate to %s', output) with open(output, 'w') as fp: @@ -431,22 +452,30 @@ def sign_csr(public_key, private_key, email, ca_url, method, port, input_file, # ---------------------------------------------------------------------- # Step 16: generate full PEM w/ chained certs (for NGINX) - output_chained = output + '.pem' - logger.info('Generating chained certificate %s', output_chained) - url = random.choice(CA_CERT_URLS) - logger.info('Chaining with %s', url) - - resp = requests.get(url) - if not resp.ok: - logger.critical('Getting CA certificate %s failed', url) - raise click.Abort() - cert_data = resp.content + logger.info('Generating chained certificate %s', chained_output) + cert_data = _get_ca_certificate() - with open(output_chained, 'w') as fp: + with open(chained_output, 'w') as fp: fp.write(signed_pem) - fp.write('\n') fp.write(cert_data) - logger.info('Chained certificate written to %s', output_chained) + logger.info('Chained certificate written to %s', chained_output) + + +def _strip_exts(path, stripext): + name, ext = os.path.splitext(path) + if ext in stripext: + return name + return path + + +def _replace_ext(path, newext, stripext): + return _strip_exts(path, stripext) + newext + + +def _ensure_extension(path, ext): + if not path.endswith(ext): + path += ext + return path class SimpleDataStructure(object): @@ -723,6 +752,28 @@ def do_GET(r): proc.join() +def _export_certificate(signed_der): + signed_der64 = base64.b64encode(signed_der) + return ( + '-----BEGIN CERTIFICATE-----\n' + '{}\n' + '-----END CERTIFICATE-----\n' + .format("\n".join(textwrap.wrap(signed_der64, 64)))) + + +def _get_ca_certificate(): + url = random.choice(CA_CERT_URLS) + logger.info('Getting CA certificate from %s', url) + + resp = requests.get(url) + if not resp.ok: + logger.warning('Getting CA certificate from %s failed', url) + raise RuntimeError('Getting CA certificate from %s failed' % url) + + cert_data = resp.content + return cert_data + + def setup_logging(): handler = logging.StreamHandler(sys.stderr) handler.setLevel(logging.INFO) From 765e1d8c0a860ce28790ef0ac330a65080dee5aa Mon Sep 17 00:00:00 2001 From: Samuele Santi Date: Mon, 30 May 2016 18:38:12 +0100 Subject: [PATCH 6/6] Update README --- README.md | 174 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 171 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ae75415..21911be 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,174 @@ -#Let's Encrypt Without Sudo +# Let's Encrypt Without Sudo + +## Abourt rshk/letsencrypt-nosudo fork + +It all started when I needed to make a few changes to make the +original +[diafygi/letsencrypt-nosudo](https://github.com/diafygi/letsencrypt-nosudo) +project in order to better fit my needs. + +The changes ended up being more than I initially thought, and many +things changed when compared to the origianl project; still the credit +for most of the verification code goes to the original authors. + +### The verification process I use + +Since I cannot afford the downtime of bringing the webserver (reverse +proxy, actually) down each time I need to update the certificates, I +come up with this solution: + +- The reverse proxy is setup to forward all connections on + `/.well-known/acme-challenge`, for the selected domains, to a + separate "verification" app. + +- All the certificate generation / validation is run locally; the HTTP + server required for verification is running locally as well; + connection is forwarded from the reverse proxy machine through a + reverse ssh tunnel. + +- Since all the keys are stored locally anyways, the script can just + as well sign the certificates automatically (running commands + manually might improve slightly on the security PoV (?), but try + doing that with dozends of domains...) + +- I also need a PEM certificate containing the full chain; the new + script takes care of that as well. + + +### Prerequisites + +The new script depends on a few extra library you'll need to +install. The recommended way to do so is to create a virtualenv: + + # Create the virtualenv + virtualenv --python /usr/bin/python2.7 .venv + + # Activate it in the current shell + source ./.venv/bin/activate + + # Install the requirements + pip install -r requirements.txt + + +### Usage example + +First of all, you'll need to generate a key pair for your user, if you +haven't already: + + ./letsencrypt_nosudo.py generate_user_key user.key + +..will generate the `user.key` and `user.pub` files. + +Then, you're going to need a private key for the domain: + + ./letsencrypt_nosudo.py generate_domain_key example.com + +..will generate the `example.com.key` file. + +Next, you'll need to generate a Certificate Sigining Request file (CSR): + + ./letsencrypt_nosudo.py generate_csr -d example.com -d www.example.com + +Ok, now for the tricky part: getting the certificate signature. + +**Prerequisite:** configure the reverse proxy to proxy requests for +the verification URL. I use a NGINX configuration similar to this: + +```nginx +server { + listen 80; + server_name example.com; + include "/etc/nginx/incl/letsencrypt-8080.conf"; + location / { + return 301 http://www.$server_name$request_uri; + } +} + +server { + listen 80; + server_name www.example.com; + include "/etc/nginx/incl/letsencrypt-8080.conf"; + + location / { + # ... forward to the app upstream ... + } +} + +server { + listen 443; + server_name example.com; + + ssl on; + ssl_certificate /etc/ssl/localcerts/example.com.pem; + ssl_certificate_key /etc/ssl/localcerts/example.com.key; + include "/etc/nginx/incl/ssl-security.conf"; + + return 301 https://www.$server_name$request_uri; +} + +server { + listen 443; + server_name www.example.com; + + ssl on; + ssl_certificate /etc/ssl/localcerts/example.com.pem; + ssl_certificate_key /etc/ssl/localcerts/example.com.key; + include "/etc/nginx/incl/ssl-security.conf"; + + location / { + # ... forward to the app upstream ... + } +} +``` + +Contents of `/etc/nginx/incl/letsencrypt-8080.conf`: + +```nginx +location /.well-known/acme-challenge/ { + proxy_pass_header Server; + proxy_set_header Host $http_host; + proxy_redirect off; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Scheme $scheme; + proxy_connect_timeout 10; + proxy_read_timeout 10; + proxy_pass http://127.0.0.1:8080/; +} +``` + +Contents of `/etc/nginx/incl/ssl-security.conf` (not really required +for this to work, but provides some good configuration for SSL): + +```nginx +# Some SSL best practices + +ssl_protocols TLSv1.2 TLSv1.1 TLSv1; +ssl_prefer_server_ciphers on; +ssl_ciphers "EECDH+AESGCM:EDH+AESGCM:ECDHE-RSA-AES128-GCM-SHA256:AES256+EECDH:DHE-RSA-AES128-GCM-SHA256:AES256+EDH:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA:ECDHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES128-SHA256:DHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES256-GCM-SHA384:AES128-GCM-SHA256:AES256-SHA256:AES128-SHA256:AES256-SHA:AES128-SHA:DES-CBC3-SHA:HIGH:!aNULL:!eNULL:!EXPORT:!DES:!MD5:!PSK:!RC4"; + +# Make sure you generate a diffie-hellman params file if you want to use this +ssl_dhparam /etc/ssl/localcerts/dhparams.pem; +``` + +**Prerequisite:** run a reverse SSH tunnel: + + ssh gateway.example.com -R localhost:8080:localhost:8080 -N -v + +Run the signing process: + + ./letsencrypt_nosudo.py sign_csr -p 8080 -m run-local -b example.com --ca-url staging + +(remove `--ca-url staging` when you're ready to generate real certificates). + +If everything is setup correctly, this will output the +`example.com.crt` and `example.com.pem` files, containing respectively +the certificate and the certificate chained with the CA certificate +(you'll want to use the second one when configuring nginx). + + +----- + +# Original README below The [Let's Encrypt](https://letsencrypt.org/) initiative is a fantastic program that offers **free** https certificates! However, the one catch is that you need @@ -420,5 +590,3 @@ clear what it's doing. For example, it currently can't do any ACME challenges besides 'http-01'. Maybe someone could do a pull request to add more challenge compatibility? - -