diff options
Diffstat (limited to 'myrpki.rototill/myrpki.py')
-rw-r--r-- | myrpki.rototill/myrpki.py | 644 |
1 files changed, 644 insertions, 0 deletions
diff --git a/myrpki.rototill/myrpki.py b/myrpki.rototill/myrpki.py new file mode 100644 index 00000000..7937521d --- /dev/null +++ b/myrpki.rototill/myrpki.py @@ -0,0 +1,644 @@ +""" +Read an OpenSSL-style config file and a bunch of .csv files to find +out about parents and children and resources and ROA requests, oh my. +Run OpenSSL command line tool to construct BPKI certificates, +including cross-certification of other entities' BPKI certificates. + +Package up all of the above as a single XML file which user can then +ship off to the IRBE. If an XML file already exists, check it for +data coming back from the IRBE (principally PKCS #10 requests for our +BSC) and update it with current data. + +The general idea here is that this one XML file contains all of the +data that needs to be exchanged as part of ordinary update operations; +each party updates it as necessary, then ships it to the other via +some secure channel: carrier pigeon, USB stick, gpg-protected email, +we don't really care. + +This one program is written a little differently from all the other +Python RPKI programs. This one program is intended to run as a +stand-alone script, without the other programs present. It does +require a reasonably up-to-date version of the OpenSSL command line +tool (the one built as a side effect of building rcynic will do), but +it does -not- require POW or any Python libraries beyond what ships +with Python 2.5. So this script uses xml.etree from the Python +standard libraries instead of lxml.etree, which sacrifices XML schema +validation support in favor of portability, and so forth. + +To make things a little weirder, as a convenience to IRBE operators, +this script can itself be loaded as a Python module and invoked as +part of another program. This requires a few minor contortions, but +avoids duplicating common code. + +$Id$ + +Copyright (C) 2009 Internet Systems Consortium ("ISC") + +Permission to use, copy, modify, and distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND ISC DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL ISC BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE +OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. +""" + +# Only standard Python libraries for this program, please. + +import subprocess, csv, re, os, getopt, sys, ConfigParser, base64 + +from xml.etree.ElementTree import Element, SubElement, ElementTree + +# Our XML namespace. + +namespace = "http://www.hactrn.net/uris/rpki/myrpki/" + +# Dialect for our use of CSV files, here to make it easy to change if +# your site needs to do something different. See doc for the csv +# module in the Python standard libraries for details if you need to +# customize this. + +csv_dialect = csv.get_dialect("excel-tab") + +# Whether to include incomplete entries when rendering to XML. + +allow_incomplete = False + +# Whether to whine about incomplete entries while rendering to XML. + +whine = False + +class comma_set(set): + """ + Minor customization of set(), to provide a print syntax. + """ + + def __str__(self): + return ",".join(self) + +class roa_request(object): + """ + Representation of a ROA request. + """ + + v4re = re.compile("^([0-9]{1,3}\.){3}[0-9]{1,3}/[0-9]+(-[0-9]+)?$", re.I) + v6re = re.compile("^([0-9a-f]{0,4}:){0,15}[0-9a-f]{0,4}/[0-9]+(-[0-9]+)?$", re.I) + + def __init__(self, asn, group): + self.asn = asn + self.group = group + self.v4 = comma_set() + self.v6 = comma_set() + + def __repr__(self): + s = "<%s asn %s group %s" % (self.__class__.__name__, self.asn, self.group) + if self.v4: + s += " v4 %s" % self.v4 + if self.v6: + s += " v6 %s" % self.v6 + return s + ">" + + def add(self, prefix): + """ + Add one prefix to this ROA request. + """ + if self.v4re.match(prefix): + self.v4.add(prefix) + elif self.v6re.match(prefix): + self.v6.add(prefix) + else: + raise RuntimeError, "Bad prefix syntax: %r" % (prefix,) + + def xml(self, e): + """ + Generate XML element represeting representing this ROA request. + """ + SubElement(e, "roa_request", + asn = self.asn, + v4 = str(self.v4), + v6 = str(self.v6)) + +class roa_requests(dict): + """ + Database of ROA requests. + """ + + def add(self, asn, group, prefix): + """ + Add one <ASN, group, prefix> set to ROA request database. + """ + key = (asn, group) + if key not in self: + self[key] = roa_request(asn, group) + self[key].add(prefix) + + def xml(self, e): + """ + Render ROA requests as XML elements. + """ + for r in self.itervalues(): + r.xml(e) + + @classmethod + def from_csv(cls, roa_csv_file): + """ + Parse ROA requests from CSV file. + """ + self = cls() + # format: p/n-m asn group + for pnm, asn, group in csv_open(roa_csv_file): + self.add(asn = asn, group = group, prefix = pnm) + return self + +class child(object): + """ + Representation of one child entity. + """ + + v4re = re.compile("^(([0-9]{1,3}\.){3}[0-9]{1,3}/[0-9]+)|(([0-9]{1,3}\.){3}[0-9]{1,3}-([0-9]{1,3}\.){3}[0-9]{1,3})$", re.I) + v6re = re.compile("^(([0-9a-f]{0,4}:){0,15}[0-9a-f]{0,4}/[0-9]+)|(([0-9a-f]{0,4}:){0,15}[0-9a-f]{0,4}-([0-9a-f]{0,4}:){0,15}[0-9a-f]{0,4})$", re.I) + + def __init__(self, handle): + self.handle = handle + self.asns = comma_set() + self.v4 = comma_set() + self.v6 = comma_set() + self.validity = None + self.bpki_certificate = None + + def __repr__(self): + s = "<%s %s" % (self.__class__.__name__, self.handle) + if self.asns: + s += " asn %s" % self.asns + if self.v4: + s += " v4 %s" % self.v4 + if self.v6: + s += " v6 %s" % self.v6 + if self.validity: + s += " valid %s" % self.validity + if self.bpki_certificate: + s += " cert %s" % self.bpki_certificate + return s + ">" + + def add(self, prefix = None, asn = None, validity = None, bpki_certificate = None): + """ + Add prefix, autonomous system number, validity date, or BPKI + certificate for this child. + """ + if prefix is not None: + if self.v4re.match(prefix): + self.v4.add(prefix) + elif self.v6re.match(prefix): + self.v6.add(prefix) + else: + raise RuntimeError, "Bad prefix syntax: %r" % (prefix,) + if asn is not None: + self.asns.add(asn) + if validity is not None: + self.validity = validity + if bpki_certificate is not None: + self.bpki_certificate = bpki_certificate + + def xml(self, e): + """ + Render this child as an XML element. + """ + complete = self.bpki_certificate and self.validity + if whine and not complete: + print "Incomplete child entry %s" % self + if complete or allow_incomplete: + e = SubElement(e, "child", + handle = self.handle, + valid_until = self.validity, + asns = str(self.asns), + v4 = str(self.v4), + v6 = str(self.v6)) + if self.bpki_certificate: + PEMElement(e, "bpki_certificate", self.bpki_certificate) + +class children(dict): + """ + Database of children. + """ + + def add(self, handle, prefix = None, asn = None, validity = None, bpki_certificate = None): + """ + Add resources to a child, creating the child object if necessary. + """ + if handle not in self: + self[handle] = child(handle) + self[handle].add(prefix = prefix, asn = asn, validity = validity, bpki_certificate = bpki_certificate) + + def xml(self, e): + """ + Render children database to XML. + """ + for c in self.itervalues(): + c.xml(e) + + @classmethod + def from_csv(cls, children_csv_file, prefix_csv_file, asn_csv_file, xcert): + """ + Parse child resources, certificates, and validity dates from CSV files. + """ + self = cls() + # childname date pemfile + for handle, date, pemfile in csv_open(children_csv_file): + self.add(handle = handle, validity = date, bpki_certificate = xcert(pemfile)) + # childname p/n + for handle, pn in csv_open(prefix_csv_file): + self.add(handle = handle, prefix = pn) + # childname asn + for handle, asn in csv_open(asn_csv_file): + self.add(handle = handle, asn = asn) + return self + +class parent(object): + """ + Representation of one parent entity. + """ + + def __init__(self, handle): + self.handle = handle + self.service_uri = None + self.bpki_cms_certificate = None + self.bpki_https_certificate = None + self.myhandle = None + self.sia_base = None + + def __repr__(self): + s = "<%s %s" % (self.__class__.__name__, self.handle) + if self.myhandle: + s += " myhandle %s" % self.myhandle + if self.service_uri: + s += " uri %s" % self.service_uri + if self.sia_base: + s += " sia %s" % self.sia_base + if self.bpki_cms_certificate: + s += " cms %s" % self.bpki_cms_certificate + if self.bpki_https_certificate: + s += " https %s" % self.bpki_https_certificate + return s + ">" + + def add(self, service_uri = None, + bpki_cms_certificate = None, + bpki_https_certificate = None, + myhandle = None, + sia_base = None): + """ + Add service URI or BPKI certificates to this parent object. + """ + if service_uri is not None: + self.service_uri = service_uri + if bpki_cms_certificate is not None: + self.bpki_cms_certificate = bpki_cms_certificate + if bpki_https_certificate is not None: + self.bpki_https_certificate = bpki_https_certificate + if myhandle is not None: + self.myhandle = myhandle + if sia_base is not None: + self.sia_base = sia_base + + def xml(self, e): + """ + Render this parent object to XML. + """ + complete = self.bpki_cms_certificate and self.bpki_https_certificate and self.myhandle and self.service_uri and self.sia_base + if whine and not complete: + print "Incomplete parent entry %s" % self + if complete or allow_incomplete: + e = SubElement(e, "parent", + handle = self.handle, + myhandle = self.myhandle, + service_uri = self.service_uri, + sia_base = self.sia_base) + if self.bpki_cms_certificate: + PEMElement(e, "bpki_cms_certificate", self.bpki_cms_certificate) + if self.bpki_https_certificate: + PEMElement(e, "bpki_https_certificate", self.bpki_https_certificate) + +class parents(dict): + """ + Database of parent objects. + """ + + def add(self, handle, + service_uri = None, + bpki_cms_certificate = None, + bpki_https_certificate = None, + myhandle = None, + sia_base = None): + """ + Add service URI or certificates to parent object, creating it if necessary. + """ + if handle not in self: + self[handle] = parent(handle) + self[handle].add(service_uri = service_uri, + bpki_cms_certificate = bpki_cms_certificate, + bpki_https_certificate = bpki_https_certificate, + myhandle = myhandle, + sia_base = sia_base) + + def xml(self, e): + for c in self.itervalues(): + c.xml(e) + + @classmethod + def from_csv(cls, parents_csv_file, xcert): + """ + Parse parent data from CSV file. + """ + self = cls() + # parentname service_uri parent_bpki_cms_pemfile parent_bpki_https_pemfile myhandle sia_base + for handle, service_uri, parent_cms_pemfile, parent_https_pemfile, myhandle, sia_base in csv_open(parents_csv_file): + self.add(handle = handle, + service_uri = service_uri, + bpki_cms_certificate = xcert(parent_cms_pemfile), + bpki_https_certificate = xcert(parent_https_pemfile), + myhandle = myhandle, + sia_base = sia_base) + return self + +def csv_open(filename): + """ + Open a CSV file, with settings that make it a tab-delimited file. + You may need to tweak this function for your environment, see the + csv module in the Python standard libraries for details. + """ + return csv.reader(open(filename, "rb"), dialect = csv_dialect) + +def PEMElement(e, tag, filename): + """ + Create an XML element containing Base64 encoded data taken from a + PEM file. + """ + lines = open(filename).readlines() + while lines: + if lines.pop(0).startswith("-----BEGIN "): + break + while lines: + if lines.pop(-1).startswith("-----END "): + break + SubElement(e, tag).text = "".join(line.strip() for line in lines) + +class CA(object): + """ + Representation of one certification authority. + """ + + # Mapping of path restriction values we use to OpenSSL config file + # section names. + + path_restriction = { 0 : "ca_x509_ext_xcert0", + 1 : "ca_x509_ext_xcert1" } + + def __init__(self, cfg, dir): + self.cfg = cfg + self.dir = dir + self.cer = dir + "/ca.cer" + self.key = dir + "/ca.key" + self.req = dir + "/ca.req" + self.crl = dir + "/ca.crl" + self.index = dir + "/index" + self.serial = dir + "/serial" + self.crlnum = dir + "/crl_number" + + self.env = { "PATH" : os.environ["PATH"], + "BPKI_DIRECTORY" : dir, + "RANDFILE" : ".OpenSSL.whines.unless.I.set.this" } + + def run_ca(self, *args): + """ + Run OpenSSL "ca" command with tailored environment variables and common initial + arguments. + """ + cmd = (openssl, "ca", "-batch", "-config", self.cfg) + args + subprocess.check_call(cmd, env = self.env) + + def run_req(self, key_file, req_file): + """ + Run OpenSSL "req" command with tailored environment variables and common arguments. + """ + if not os.path.exists(key_file) or not os.path.exists(req_file): + subprocess.check_call((openssl, "req", "-new", "-sha256", "-newkey", "rsa:2048", + "-config", self.cfg, "-keyout", key_file, "-out", req_file), + env = self.env) + + @staticmethod + def touch_file(filename, content = None): + """ + Create dumb little text files expected by OpenSSL "ca" utility. + """ + if not os.path.exists(filename): + f = open(filename, "w") + if content is not None: + f.write(content) + f.close() + + def setup(self, ca_name): + """ + Set up this CA. ca_name is an X.509 distinguished name in + /tag=val/tag=val format. + """ + + modified = False + + if not os.path.exists(self.dir): + os.makedirs(self.dir) + self.touch_file(self.index) + self.touch_file(self.serial, "01\n") + self.touch_file(self.crlnum, "01\n") + + self.run_req(key_file = self.key, req_file = self.req) + + if not os.path.exists(self.cer): + modified = True + self.run_ca("-selfsign", "-extensions", "ca_x509_ext_ca", "-subj", ca_name, "-in", self.req, "-out", self.cer) + + if not os.path.exists(self.crl): + modified = True + self.run_ca("-gencrl", "-out", self.crl) + + return modified + + def ee(self, ee_name, base_name): + """ + Issue an end-enity certificate. + """ + key_file = "%s/%s.key" % (self.dir, base_name) + req_file = "%s/%s.req" % (self.dir, base_name) + cer_file = "%s/%s.cer" % (self.dir, base_name) + self.run_req(key_file = key_file, req_file = req_file) + if not os.path.exists(cer_file): + self.run_ca("-extensions", "ca_x509_ext_ee", "-subj", ee_name, "-in", req_file, "-out", cer_file) + return True + else: + return False + + def bsc(self, pkcs10): + """ + Issue BSC certificiate, if we have a PKCS #10 request for it. + """ + + if pkcs10 is None: + return None, None + + pkcs10 = base64.b64decode(pkcs10) + + assert pkcs10 + + p = subprocess.Popen((openssl, "dgst", "-md5"), stdin = subprocess.PIPE, stdout = subprocess.PIPE) + hash = p.communicate(pkcs10)[0].strip() + if p.wait() != 0: + raise RuntimeError, "Couldn't hash PKCS#10 request" + + req_file = "%s/bsc.%s.req" % (self.dir, hash) + cer_file = "%s/bsc.%s.cer" % (self.dir, hash) + + if not os.path.exists(cer_file): + + p = subprocess.Popen((openssl, "req", "-inform", "DER", "-out", req_file), stdin = subprocess.PIPE) + p.communicate(pkcs10) + if p.wait() != 0: + raise RuntimeError, "Couldn't store PKCS #10 request" + + self.run_ca("-extensions", "ca_x509_ext_ee", "-in", req_file, "-out", cer_file) + + return req_file, cer_file + + def fxcert(self, filename, cert, path_restriction = 0): + """ + Write PEM certificate to file, then cross-certify. + """ + fn = os.path.join(self.dir, filename) + f = open(fn, "w") + f.write(cert) + f.close() + return self.xcert(fn, path_restriction) + + def xcert(self, cert, path_restriction = 0): + """ + Cross-certify a certificate represented as a PEM file. + """ + + if not cert: + return None + + if not os.path.exists(cert): + #print "Certificate %s doesn't exist, skipping" % cert + return None + + # Extract public key and subject name from PEM file and hash it so + # we can use the result as a tag for cross-certifying this cert. + + p1 = subprocess.Popen((openssl, "x509", "-noout", "-pubkey", "-subject", "-in", cert), stdout = subprocess.PIPE) + p2 = subprocess.Popen((openssl, "dgst", "-md5"), stdin = p1.stdout, stdout = subprocess.PIPE) + + xcert = "%s/xcert.%s.cer" % (self.dir, p2.communicate()[0].strip()) + + if p1.wait() != 0 or p2.wait() != 0: + raise RuntimeError, "Couldn't generate cross-certification tag for %r" % cert + + # Cross-certify the cert we were given, if we haven't already. + # This only works for self-signed certs, due to limitations of the + # OpenSSL command line tool, but that suffices for our purposes. + + if not os.path.exists(xcert): + self.run_ca("-ss_cert", cert, "-out", xcert, "-extensions", self.path_restriction[path_restriction]) + + return xcert + +def extract_resources(): + """ + Extract RFC 3779 resources from a certificate. Not written yet. + + """ + raise NotImplementedError + + +def main(argv = ()): + """ + Main program. Must be callable from other programs as well as being + invoked directly when this module is run as a script. + """ + + cfg_file = "myrpki.conf" + section = "myrpki" + + opts, argv = getopt.getopt(argv, "c:h:?", ["config=", "help"]) + for o, a in opts: + if o in ("-h", "--help", "-?"): + print __doc__ + sys.exit(0) + elif o in ("-c", "--config"): + cfg_file = a + if argv: + raise RuntimeError, "Unexpected arguments %r" % (argv,) + + cfg = ConfigParser.RawConfigParser() + cfg.readfp(open(cfg_file, "r"), cfg_file) + + my_handle = cfg.get(section, "handle") + roa_csv_file = cfg.get(section, "roa_csv") + children_csv_file = cfg.get(section, "children_csv") + parents_csv_file = cfg.get(section, "parents_csv") + prefix_csv_file = cfg.get(section, "prefix_csv") + asn_csv_file = cfg.get(section, "asn_csv") + bpki_dir = cfg.get(section, "bpki_directory") + xml_filename = cfg.get(section, "xml_filename") + repository_bpki_certificate = cfg.get(section, "repository_bpki_certificate") + repository_handle = cfg.get(section, "repository_handle") + + global openssl + openssl = cfg.get(section, "openssl") if cfg.has_option(section, "openssl") else "openssl" + + bpki = CA(cfg_file, bpki_dir) + bpki.setup("/CN=%s TA" % my_handle) + + if os.path.exists(xml_filename): + e = ElementTree(file = xml_filename).getroot() + bsc_req, bsc_cer = bpki.bsc(e.findtext("{%s}%s" % (namespace, "bpki_bsc_pkcs10"))) + else: + bsc_req, bsc_cer = None, None + + e = Element("myrpki", xmlns = namespace, version = "1", handle = my_handle, repository_handle = repository_handle) + + roa_requests.from_csv(roa_csv_file).xml(e) + + children.from_csv( + children_csv_file = children_csv_file, + prefix_csv_file = prefix_csv_file, + asn_csv_file = asn_csv_file, + xcert = bpki.xcert).xml(e) + + parents.from_csv( + parents_csv_file = parents_csv_file, + xcert = bpki.xcert).xml(e) + + PEMElement(e, "bpki_ca_certificate", bpki.cer) + PEMElement(e, "bpki_crl", bpki.crl) + + if os.path.exists(repository_bpki_certificate): + PEMElement(e, "bpki_repository_certificate", bpki.xcert(repository_bpki_certificate)) + + if bsc_cer: + PEMElement(e, "bpki_bsc_certificate", bsc_cer) + + if bsc_req: + PEMElement(e, "bpki_bsc_pkcs10", bsc_req) + + # I still miss SYSCAL(RENMWO) + + ElementTree(e).write(xml_filename + ".tmp") + os.rename(xml_filename + ".tmp", xml_filename) + +# When this file is run as a script, run main() with command line +# arguments. main() can't use sys.argv directly as that might be the +# command line for some other program that loads this module. + +if __name__ == "__main__": + main(sys.argv[1:]) |