From dab3ad88c5b0cc497cc695922c74f8d2c6ab3a63 Mon Sep 17 00:00:00 2001 From: Christoph Egger Date: Fri, 28 Oct 2016 00:23:16 +0200 Subject: [PATCH] Initial import --- bin/addrecord | 135 ++++++++++++++++++++++++++++ bin/newcert | 155 ++++++++++++++++++++++++++++++++ bin/newhost | 63 +++++++++++++ config/inventory.yaml | 103 +++++++++++++++++++++ sicceggetools/__init__.py | 0 sicceggetools/acme/__init__.py | 0 sicceggetools/acme/constants.py | 6 ++ 7 files changed, 462 insertions(+) create mode 100644 bin/addrecord create mode 100755 bin/newcert create mode 100755 bin/newhost create mode 100644 config/inventory.yaml create mode 100644 sicceggetools/__init__.py create mode 100644 sicceggetools/acme/__init__.py create mode 100644 sicceggetools/acme/constants.py diff --git a/bin/addrecord b/bin/addrecord new file mode 100644 index 0000000..cf2c573 --- /dev/null +++ b/bin/addrecord @@ -0,0 +1,135 @@ +#!/usr/bin/python3 +# +# ssh-based server to allow hosts to update their own TLS-related RRs +# intended to be used like +# command="/srv/tls/bin/addrecord hepworth.siccegge.de" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOIlLx3R+Q5LgBZbJ6USuzam/uAEQITl6vzOn/ylk4fq christoph@mitoraj + +from subprocess import call +from functools import reduce +import datetime +import fcntl +import os +import re +import sys +import yaml + +class Addrecord: + def __init__(self, host): + self._host = host + self._ports = {'www': [443], + 'smtp': [25, 587, 465], + 'imap': [993, 143], + 'pop': [110, 995], + 'xmpp': [5222, 5269], + } + self._tlsa_re = re.compile('TLSA 3 1 1 [0-9a-f]{64}') + self._acme_re = re.compile(r'[a-zA-Z0-9_-]{43}') + with open('config/inventory.yaml') as inv: + self._inventory = yaml.load(inv) + + + def tlsa(self): + host = input("Hostname: ") + service = input("Service: ") + value = input("Value: ") + if host not in self._allowed_names(service): + sys.stderr.write("Not authorized to update entries for service '%s' on '%s'\n" + % (service, host)) + return 1 + + if re.fullmatch(self._tlsa_re, value) is None: + sys.stderr.write("Not a valid TLSA record: '%s'\n" % value) + return 2 + + records = [] + for port in self._ports[service]: + records.append("{0:<35s}\tIN\t{1}.\n".format("_%d._tcp.%s" % (port, host), + value)) + self._update_records('tlsa', host, records) + print("OK") + return 0 + + + def acme(self): + host = input("Hostname: ") + value = input("Value: ") + + if host not in self._allowed_names(): + sys.stderr.write("Not authorized to update entries for host '%s'\n" % host) + return 1 + + if re.fullmatch(self._acme_re, value) is None: + sys.stderr.write("Not a valid ACME challenge record: '%s'\n" % value) + return 2 + + records = [ "{0:<35s}.\tIN\tTXT\t\"{1}\"\n".format("_acme-challenge.%s" % host, value) ] + self._update_records('acme', host, records) + print("OK") + return 0 + + + def _update_records(self, sort, host, records): + def find_zone(host): + assert len(host) != 0 + candidate = os.path.join(sort, '.'.join(host + ['m4'])) + if os.path.exists(candidate): + return candidate + else: + return find_zone(host[1:]) + + + to_remove = [ i.split()[0] for i in records ] + + zone = find_zone(host.split('.')) + with open(zone, 'r') as oldzone: + fcntl.flock(oldzone, fcntl.LOCK_EX) + lines = oldzone.readlines() + lines = [ line for line in lines if line == '\n' or line.split()[0] not in to_remove ] + + lines.append('\n') + lines.append("; Last updated %s by %s\n" % (datetime.datetime.utcnow().isoformat(), + self._host)) + lines = lines + records + + with open('%s.new' % (zone,), 'w') as newzone: + newtext = ''.join(lines) + newtext = re.sub(r'\n[\n]+', '\n\n', newtext) + newtext = re.sub(r'\n;.*\n\n;', '\n;', newtext) + newzone.write(newtext) + + os.rename('%s.new' % (zone,), + zone) + fcntl.flock(oldzone, fcntl.LOCK_UN) + call(["ssh", "root@localhost"]) + + + def _allowed_names(self, service=None): + if service is None: + perservice = self._inventory[self._host].values() + else: + perservice = self._inventory[self._host][service] + + names = set() + for entry in perservice: + if type(entry) is list: + names = names.union(entry) + elif type(entry) is dict: + names = names.union(reduce(lambda x, y: x + y, entry.values(), [])) + else: + sys.stderr.write("inventory format wrong\n") + return names + + +def main(): + command = os.environ['SSH_ORIGINAL_COMMAND'] + host = sys.argv[1] + addrecord = Addrecord(host) + + if command == 'acme': + return addrecord.acme() + elif command == 'tlsa': + return addrecord.tlsa() + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/bin/newcert b/bin/newcert new file mode 100755 index 0000000..6817b70 --- /dev/null +++ b/bin/newcert @@ -0,0 +1,155 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# (C) Christoph Egger + +from __future__ import print_function + +from socket import getfqdn +import argparse +import logging +import os.path +import yaml +import time + +from acme import client +from acme import jose +from acme import messages +from acme import challenges + +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives import hashes +from cryptography import x509 +from cryptography.x509.oid import NameOID + +import OpenSSL + +import pexpect + +from sicceggetools.acme import constants + + +def get_client(): + logging.info("Loading account key") + with open("data/account.key.pem", "rb") as keyfd: + private_key = serialization.load_pem_private_key( + keyfd.read(), + password=None, + backend=default_backend() + ) + + logging.info("Loading account registration") + with open("data/registration.json", "rb") as regfd: + registration = messages.RegistrationResource.json_loads(regfd.read()) + + account_key = jose.JWKRSA(key=private_key) + acme_client = client.Client(constants.DIRECTORY_URL, account_key) + + return registration, acme_client, account_key + + +def authorize(sans): + registration, acme_client, account_key = get_client() + authorizations = [] + for san in sans: + authzr = acme_client.request_challenges( + identifier=messages.Identifier(typ=messages.IDENTIFIER_FQDN, value=san), + new_authzr_uri=registration.new_authzr_uri) + authorizations.append(authzr) + for challenge in authzr.body.challenges: + if isinstance(challenge.chall, challenges.DNS01): + print(san) + print(challenge.validation(account_key)) + print(challenge.key_authorization(account_key)) + ssh = pexpect.spawn("ssh _tls@ns1.siccegge.de acme") + ssh.expect("Hostname:") + ssh.sendline(san) + ssh.expect("Value:") + ssh.sendline(challenge.validation(account_key)) + ssh.expect("OK") + + break + else: + print("fallthrough") + + time.sleep(5) + for authorization in authorizations: + for challenge in authorization.body.challenges: + if isinstance(challenge.chall, challenges.DNS01): + response = challenges.DNS01Response(key_authorization=challenge.key_authorization(account_key)) + acme_client.answer_challenge(challenge, response) + + while(True): + print("sleeping") + time.sleep(5) + new_authorizations = [] + for authorization in authorizations: + new_auth, response = acme_client.poll(authorization) + new_authorizations.append(new_auth) + if new_auth.body.status != messages.Status("valid"): + break + else: + return new_authorizations + + +def get_certificate(cname, sans): + registration, acme_client, account_key = get_client() + authorizations = authorize(sans) + + with open(os.path.join("certs", cname, "key.pem"), "rb") as keyfd: + private_key = serialization.load_pem_private_key( + keyfd.read(), + password=None, + backend=default_backend()) + + builder = x509.CertificateSigningRequestBuilder() + builder = builder.subject_name(x509.Name([ + x509.NameAttribute(NameOID.COMMON_NAME, cname.decode()), + ])) + builder = builder.add_extension( + x509.SubjectAlternativeName([x509.DNSName(x.decode()) for x in sans]), + critical=False) + + request = builder.sign(private_key, hashes.SHA512(), default_backend()) + orequest = OpenSSL.crypto.load_certificate_request( + OpenSSL.crypto.FILETYPE_PEM, + request.public_bytes(serialization.Encoding.PEM)) + + jrequest = jose.util.ComparableX509(orequest) + cert = acme_client.request_issuance(jrequest, authorizations) + certs = acme_client.fetch_chain(cert) + + with open(os.path.join("certs", cname, "cert.pem"), "wb") as certfd: + certfd.write(cert.body._dump(OpenSSL.crypto.FILETYPE_PEM)) + for cert in certs: + certfd.write(cert._dump(OpenSSL.crypto.FILETYPE_PEM)) + + print(cname) + print(sans) + print(cert) + + +def main(): + parser = argparse.ArgumentParser() + + parser.add_argument('--servicetype', '-s', type=str) + parser.add_argument('certificate', type=str) + args = parser.parse_args() + + with open("config/inventory.yaml") as invfd: + inventory = yaml.load(invfd.read()) + + certificate_list = inventory[getfqdn()][args.servicetype] + if type(certificate_list) is list: + if args.certificate in certificate_list: + get_certificate(args.certificate, [args.certificate]) + elif type(certificate_list) is dict: + if args.certificate in certificate_list.keys(): + get_certificate(args.certificate, certificate_list[args.certificate]) + else: + print("unexpected type: %s", type(certificate_list)) + + +if __name__ == '__main__': + main() diff --git a/bin/newhost b/bin/newhost new file mode 100755 index 0000000..b6122b1 --- /dev/null +++ b/bin/newhost @@ -0,0 +1,63 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# (C) Christoph Egger + +from __future__ import print_function + +import os.path +import logging + +from acme import client +from acme import jose +from acme import messages + +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives import serialization + +from sicceggetools.acme import constants + +logging.basicConfig() +logging.getLogger().setLevel(logging.INFO) + +if not os.path.exists("data"): + logging.info("Creating data directory") + os.mkdir("data") + os.chmod("data", 0700) + + +if not os.path.exists("data/account.key.pem"): + logging.info("Creating account key") + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=constants.KEY_SIZE, + backend=default_backend() + ) + + pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption() + ) + + with open("data/account.key.pem", "wb") as keyfd: + keyfd.write(pem) +else: + logging.info("Loading account key") + with open("data/account.key.pem", "rb") as keyfd: + private_key = serialization.load_pem_private_key( + keyfd.read(), + password=None, + backend=default_backend() + ) + +if not os.path.exists("data/registration.json"): + logging.info("registering") + acmeclient = client.Client(constants.DIRECTORY_URL, jose.JWKRSA(key=private_key)) + registration = messages.NewRegistration(contact=constants.CONTACT) + registration = acmeclient.register(registration) + registration = acmeclient.agree_to_tos(registration) + + with open("data/registration.json", "wb") as regfd: + regfd.write(registration.json_dumps_pretty()) + diff --git a/config/inventory.yaml b/config/inventory.yaml new file mode 100644 index 0000000..21c2dc6 --- /dev/null +++ b/config/inventory.yaml @@ -0,0 +1,103 @@ +gandi.siccegge.de: + www: + siccegge.de: + - siccegge.de + - static.siccegge.de + - www.siccegge.de + pim.siccegge.de: + - kalender.egger.im + - pim.siccegge.de + - radicale.siccegge.de + frida.xyz: + - doc.frida.xyz + - frida.xyz + - www.frida.xyz + avatars.siccegge.de: + - avatars.siccegge.de + fsinf.fau.fail: + - fsinf.fau.fail + git.siccegge.de: + - git.siccegge.de + katarakt.faui2k9.de: + - katarakt.faui2k9.de + pdfgrep.org: + - pdfgrep.org + - www.pdfgrep.org + planet.faui2k9.de: + - planet.faui2k9.de + privat.egger.im: + - privat.egger.im + taz.siccegge.de: + - taz.siccegge.de + weblog.siccegge.de: + - weblog.siccegge.de + xmpp: + egger.im: + - egger.im + - conference.egger.im + faui2k9.de: + - faui2k9.de + - conference.faui2k9.de + smtp: + - mx4.siccegge.de + +chadwick.siccegge.de: + smtp: + - mx2.siccegge.de + www: + lists.faui2k9.de: + - lists.christoph-egger.org + - lists.faui2k9.de + - lists.frida.xyz + - lists.pdfgrep.org + tt-rss.faui2k9.de: + - tt-rss.faui2k9.de + +1und1.siccegge.de: + smtp: + - mx3.siccegge.de + www: + jbn-bobingen.de: + - backend.jbn-bobingen.de + - jbn-bobingen.de + - www.jbn-bobingen.de + christoph-egger.org: + - christoph-egger.org + - www.christoph-egger.org + poll.faui2k9.de: + - poll.faui2k9.de + stats.siccegge.de: + - stats.coders-nemesis.eu + - stats.siccegge.de + meetings.christoph-egger.org: + - meetings.christoph-egger.org + +oteiza.siccegge.de: + smtp: + - mx1.siccegge.de + +bistolfi.siccegge.de: + www: + wot.siccegge.de: + - wot.christoph-egger.org + - wot.siccegge.de + annex.siccegge.de: + - annex.siccegge.de + projects.faui2k9.de: + - projects.faui2k9.de + - userdata.projects.faui2k9.de + +phabricator.siccegge.de: + www: + projects.faui2k9.de: + - projects.faui2k9.de + - userdata.projects.faui2k9.de + +hepworth.siccegge.de: + www: + hepworth.siccegge.de: + - hepworth.siccegge.de + - www.hepworth.siccegge.de + - test.hepworth.siccegge.de + smtp: + - hepworth.siccegge.de \ No newline at end of file diff --git a/sicceggetools/__init__.py b/sicceggetools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sicceggetools/acme/__init__.py b/sicceggetools/acme/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sicceggetools/acme/constants.py b/sicceggetools/acme/constants.py new file mode 100644 index 0000000..f419b9d --- /dev/null +++ b/sicceggetools/acme/constants.py @@ -0,0 +1,6 @@ + +DIRECTORY_URL = 'https://acme-staging.api.letsencrypt.org/directory' + +KEY_SIZE = 4096 + +CONTACT = ('mailto:certmaster@siccegge.de',) -- 2.39.2