From a999aa469356eed2f6017f40516f0e7bb6deca5b Mon Sep 17 00:00:00 2001 From: Nalin Dahyabhai Date: Jun 14 2016 15:12:04 +0000 Subject: Add a few example helpers in Python Add a few example minimal helpers written in Python to illustrate how they're generally expected to work. --- diff --git a/doc/helpers/anchor-submit.py b/doc/helpers/anchor-submit.py new file mode 100644 index 0000000..5600f31 --- /dev/null +++ b/doc/helpers/anchor-submit.py @@ -0,0 +1,117 @@ +#!/usr/bin/python +""" + Overly simplistic certmonger helper, conforming to + https://git.fedorahosted.org/cgit/certmonger.git/tree/doc/helpers.txt + and using the anchor v1 API documented at + https://github.com/openstack/anchor/blob/master/doc/source/api.rst + + Error handling could use some work, and the configuration and client + credentials are hard-coded below. +""" +import os +import sys + +import requests + +def main(): + """ + Check $CERTMONGER_OPERATION to see what we should do. We only support the + (mandatory) SUBMIT and (optional) IDENTIFY and FETCH-ROOTS operations, and + only FETCH-ROOTS if the (hard-coded) server address uses the "https" + scheme. For every other operation, we exit with status indicating that we + don't support it. + + If the operation isn't set, we assume we're in SUBMIT mode, and to make + things easier to troubleshoot, if the CSR isn't provided in the + environment, we try to read one from stdin. + + If we're in SUBMIT mode, we post the CSR to the "sign" endpoint for the + "default" RA and read back either a certificate or an error message. + + In FETCH-ROOTS mode, we read the root certificate and relay it back. + + In IDENTIFY mode, we just output version information. + + Anything else, we don't support. + """ + + # These would be better off as command line options or settings in a + # configuration file, but since this is just a sample, we just hard-code + # them. + server = "http://localhost:5016/v1/%s/default" + user = "myusername" + secret = "simplepassword" + + # Key off of what we're being asked to do. We always get an instant reply, + # so we won't ever be called to "POLL". We don't currently have any + # parameters that absolutely must be specified, so there's no need to list + # them in out in a handler for "GET-NEW-REQUEST-REQUIREMENTS". We don't + # have a notion of enrollment profiles, so there's no need to handle + # "GET-SUPPORTED-TEMPLATES". + operation = os.getenv("CERTMONGER_OPERATION") + + if operation == "IDENTIFY": + # Output some version information. + sys.stdout.write("Anchor (anchor-submit.py 0.0)\n") + sys.exit(0) + + if operation is None or operation == "SUBMIT": + # Submit the signing request. If we succeed, print it and return 0. + pem = os.getenv("CERTMONGER_CSR") + # Make it easier to debug this tool, and troubleshoot using this tool, + # by attempting to read the CSR from stdin if it isn't provided in the + # environment. (The daemon always invokes us with the value set in the + # environment and stdin connected to /dev/null.) + if pem is None: + pem = sys.stdin.read() + payload = {} + payload["user"] = user + payload["secret"] = secret + payload["encoding"] = "pem" + payload["csr"] = pem + response = requests.post(url=(server%"sign"), data=payload) + if response is not None and response.ok and \ + response.text is not None and response.text != "": + # Succeeded! Send the PEM-formatted certificate to stdout and exit + # with status 0 to indicate success. + sys.stdout.write(response.text) + sys.exit(0) + else: + # Rejected? Print the error message and exit with status 2 to + # indicate that the CA rejected our request. + if response != None: + sys.stdout.write(response.text.replace("\n\n", "\n").strip()+"\n") + sys.exit(2) + + if operation == "FETCH-ROOTS": + if server.startswith("https:"): + # Fetch the root certificate. The expected output format is kind of funky, + # but since we don't have anything elaborate, we're not too upset about it. + payload = {} + payload["encoding"] = "pem" + response = requests.get(url=(server%"ca"), params=payload) + if response != None and response.ok and response.text != None and response.text != "": + # Succeeded! Send a suggested nickname and the PEM-formatted + # certificate to stdout and exit with status 0 to indicate + # success. (If the CA starts sending us more than one + # certificate, or switches to a different format, we'll have to + # do some parsing, but for now this is enough.) + sys.stdout.write("Anchor CA Root Certificate\n") + sys.stdout.write(response.text) + sys.exit(0) + else: + # Rejected? Print the error message and exit with status 2 to + # indicate that the CA rejected our request, though that really + # shouldn't happen. + if response != None: + sys.stdout.write(response.text.replace("\n\n", "\n").strip()+"\n") + sys.exit(2) + else: + # We don't support fetching roots from non-authenticated sources, + # so indicate that we don't support fetching them. + sys.exit(6) + + # We don't support this operation, whatever it is, so signal that. + sys.exit(6) + +main() diff --git a/doc/helpers/local-cryptography.py b/doc/helpers/local-cryptography.py new file mode 100644 index 0000000..397b32c --- /dev/null +++ b/doc/helpers/local-cryptography.py @@ -0,0 +1,337 @@ +#!/usr/bin/python +""" + Sample certmonger helper, conforming to + https://git.fedorahosted.org/cgit/certmonger.git/tree/doc/helpers.txt + which, rather than ferrying a signing request to a CA, signs things itself + using python-cryptography, and produces its own certificates when asked + for a list of root certificates. +""" +import datetime +import fcntl +import os +import sys +import uuid +from cryptography import utils +from cryptography import x509 +from cryptography.x509.oid import NameOID +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives import hashes + +def create_ca_key(statedir, filename, password): + """ + Creates a new private key for the CA, storing it in the specified file in + the specified directory, protected by the specified password. Any key that + was already there is overwritten. + + Returns the key. + """ + key = rsa.generate_private_key(public_exponent=0x10001, + key_size=2048, + backend=default_backend()) + encryption_algorithm = serialization.BestAvailableEncryption(password) + keystring = key.private_bytes(encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=encryption_algorithm) + kfile = open(os.path.join(statedir, filename), mode="wb") + kfile.write(keystring) + kfile.close() + return key + +def load_ca_key(statedir, filename, password): + """ + Attempt to load the private key for the CA from the specified file in the + specified directory, decrypting it with the specified password. If we fail + to read it due to an IOError, assume it hasn't been created yet and call + create_ca_key() to generate it. + + Returns the key. + """ + try: + kfile = open(os.path.join(statedir, filename), mode="rb") + keystring = kfile.read() + kfile.close() + key = serialization.load_pem_private_key(keystring, + password, + backend=default_backend()) + except IOError: + key = create_ca_key(statedir, filename, password) + return key + +def save_ca_cert(statedir, certfile, cert): + """ + Saves a certificate in PEM form in the specified file in the specified + directory. + + Returns nothing. + """ + cfile = open(os.path.join(statedir, certfile), mode="wb") + cfile.write(cert.public_bytes(encoding=serialization.Encoding.PEM)) + cfile.close() + +def save_ca_serial(statedir, serialfile, serial): + """ + Saves a serial number in big endian binary form in the specified file in + the specified directory. + + Returns nothing. + """ + sfile = open(os.path.join(statedir, serialfile), mode="wb") + sfile.write(utils.int_to_bytes(serial)) + sfile.close() + +def create_ca_cert(cakey, subject, serial): + """ + Creates a CA certificate for the specified private key with the specified + subject and serial number. If the serial number is not specified, generate + a UUID and treat it as a 128-bit serial number. If the subject name is not + specified, generate a UUID and use it both as a serial number and to + construct a subject name. + + The root certificate has a validity period of one year. + + Returns the new certificate and the serial number as an integer. + """ + if subject is None: + certuuid = uuid.uuid4() + certuuidstring = str(certuuid).encode('ascii').decode('ascii') + subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, u"Local Signing Authority"), + x509.NameAttribute(NameOID.COMMON_NAME, certuuidstring)]) + serial = utils.int_from_bytes(certuuid.bytes, 'big') + if serial is None: + certuuid = uuid.uuid4() + certuuidstring = str(certuuid).encode('ascii').decode('ascii') + serial = utils.int_from_bytes(certuuid.bytes, 'big') + now = datetime.datetime.utcnow() + certpubkey = cakey.public_key() + certski = x509.SubjectKeyIdentifier.from_public_key(certpubkey) + certaki = x509.AuthorityKeyIdentifier.from_issuer_public_key(certpubkey) + certku = x509.KeyUsage(True, False, False, False, False, True, True, False, False) + certbasic = x509.BasicConstraints(ca=True, path_length=None) + builder = x509.CertificateBuilder() + builder = builder.subject_name(subject) + builder = builder.issuer_name(subject) + builder = builder.serial_number(serial) + builder = builder.not_valid_before(now) + builder = builder.not_valid_after(now.replace(year=now.year+1)) + builder = builder.public_key(certpubkey) + builder = builder.add_extension(certski, False) + builder = builder.add_extension(certaki, False) + builder = builder.add_extension(certku, False) + builder = builder.add_extension(certbasic, True) + cert = builder.sign(cakey, hashes.SHA256(), default_backend()) + return cert, serial + +def create_ca_cert_and_serial(statedir, cakey, certfile, serialfile): + """ + Creates a new CA certificate for the specified private key, saving them + both to specified files in the specified directory. + + Returns the new certificate and the serial number as an integer. + """ + (cert, serial) = create_ca_cert(cakey, None, None) + save_ca_cert(statedir, certfile, cert) + save_ca_serial(statedir, serialfile, serial) + return cert, serial + +def load_ca_cert_and_key_and_serial(statedir, keyfile, password, certfile, serialfile): + """ + Reads the private key, CA certificate, and last-used serial number from the + specified directory and files. If the private key hasn't been created yet, + creates it first. If the certificate hasn't been created yet, generate a + new one and a new serial number. + + If the certificate has less than half a year left before it goes invalid, + generate a new key, use it to generate a new root certificate, and save it + as the first in a file of possibly more than one certificate. + + Returns the signing certificate, its private key, and the last-used serial + number as an integer. + """ + cakey = load_ca_key(statedir, keyfile, password) + try: + cfile = open(os.path.join(statedir, certfile), mode="rb") + oldcerts = cfile.read() + cert = x509.load_pem_x509_certificate(oldcerts, backend=default_backend()) + cfile.close() + cfile = open(os.path.join(statedir, serialfile), mode="rb") + serial = utils.int_from_bytes(cfile.read(), 'big') + cfile.close() + except IOError: + (cert, serial) = create_ca_cert_and_serial(statedir, cakey, certfile, serialfile) + return cert, cakey, serial + now = datetime.datetime.utcnow() + if now.month > 6: + threshold = now.replace(year=now.year+1, month=now.month-6) + else: + threshold = now.replace(month=now.month+6) + if cert.not_valid_after < threshold: + # Running out of time -> generate a new key and cert, and save the + # old cert(s) in the bundle. + newkey = create_ca_key(statedir, keyfile+".new", password) + newcert, newserial = create_ca_cert(newkey, cert.subject, serial + 1) + save_ca_cert(statedir, certfile+".new", newcert) + cfile = open(os.path.join(statedir, certfile+".new"), mode="ab") + cfile.write(oldcerts) + cfile.close() + os.rename(os.path.join(statedir, keyfile+".new"), + os.path.join(statedir, keyfile)) + os.rename(os.path.join(statedir, certfile+".new"), + os.path.join(statedir, certfile)) + save_ca_serial(statedir, serialfile, newserial) + return newcert, newkey, newserial + else: + return cert, cakey, serial + +def submit(statedir, password): + """ + The main handler for SUBMIT operations. + + Take the signing request and build a certificate using the requested + subject name and public key. Add any requested extensions that we're not + going to add on our own, then add the ones that we know values for. Sign + the result with a not-valid-after date that matches that of the signing + certificate. + + Outputs the new certificate in PEM form on stdout and returns 0 to indicate + success. + """ + csr = x509.load_pem_x509_csr(os.environ[u"CERTMONGER_CSR"].encode('utf8'), + backend=default_backend()) + cacert, cakey, serial = load_ca_cert_and_key_and_serial(statedir, + "ca.key", + password, + "ca.crt", + "ca.srl") + extensions = [] + for ext in csr.extensions: + if ext.oid != x509.ExtensionOID.BASIC_CONSTRAINTS: + if ext.oid != x509.ExtensionOID.SUBJECT_KEY_IDENTIFIER: + if ext.oid != x509.ExtensionOID.AUTHORITY_KEY_IDENTIFIER: + extensions = extensions + [ext] + builder = x509.CertificateBuilder(extensions=extensions) + builder = builder.subject_name(csr.subject) + builder = builder.issuer_name(cacert.subject) + builder = builder.serial_number(serial + 1) + now = datetime.datetime.utcnow() + builder = builder.not_valid_before(now) + builder = builder.not_valid_after(cacert.not_valid_after) + pubkey = csr.public_key() + builder = builder.public_key(pubkey) + ski = x509.SubjectKeyIdentifier.from_public_key(pubkey) + builder = builder.add_extension(ski, False) + aki = x509.AuthorityKeyIdentifier.from_issuer_public_key(cacert.public_key()) + builder = builder.add_extension(aki, False) + basic = x509.BasicConstraints(ca=False, path_length=None) + builder = builder.add_extension(basic, True) + issued = builder.sign(cakey, hashes.SHA256(), default_backend()) + issuedbytes = issued.public_bytes(encoding=serialization.Encoding.PEM) + save_ca_serial(statedir, "ca.srl", serial + 1) + sys.stdout.write(issuedbytes.decode('utf8')) + return 0 + +def identify(): + """ + The main handler for IDENTIFY operations. + + Outputs version information and exit with status 0 to indicate success. + """ + sys.stdout.write("Local Signing Authority (local-cryptography.py 0.0)\n") + return 0 + +def fetch_roots(statedir, password): + """ + The main handler for FETCH-ROOTS operations. + + After ensuring we have at least one signing certificate, we scan the file + in which we store the roots and output the PEM-formatted certificates, one + by one, preceded by suggested nicknames to be used when they're saved to + NSS databases. + + We're loading them from local disk, so we assume that their values are + authenticated. If we were retrieving them over a network, we'd have to + make sure to read them over an authenticated channel in order to avoid + potentially allowing a malicious party to inject their own certificate into + the list of trusted certificates. + + Exit with status 0 to indicate success. + """ + load_ca_cert_and_key_and_serial(statedir, "ca.key", password, + "ca.crt", "ca.srl") + try: + cfile = open(os.path.join(statedir, "ca.crt"), mode="rb") + except OSError: + return 0 + certlines = cfile.readlines() + cfile.close() + certdata = bytes() + which = 1 + for certline in certlines: + certdata = certdata + certline + if certline.decode('utf8').startswith("-----END CERTIFICATE-----"): + if len(certdata) > 0: + try: + cert = x509.load_pem_x509_certificate(certdata, backend=default_backend()) + if which > 1: + sys.stdout.write("Local Signing Authority #%d\n" % which) + else: + sys.stdout.write("Local Signing Authority\n") + certbytes = cert.public_bytes(encoding=serialization.Encoding.PEM) + sys.stdout.write(certbytes.decode('utf8')) + which = which + 1 + except IOError: + pass + certdata = bytes() + return 0 + +def main(): + """ + Consult $CERTMONGER_OPERATION to figure out what we're being asked to do. + + If the variable isn't set, assume it was meant to be "SUBMIT". + If we have "SUBMIT" as a value, try to read the signing request from the + environment. If we fail to read one, try to read from stdin to make using + this tool in a troubleshooting environment a little bit easier. + + We're hard-coded to store our data in /tmp, and encrypt private keys using + a fixed password. + + If we don't know how to do what we're being asked, exit with status 6 to + indicate that. + """ + # We're going to need our own key and CA certificate, so decide where we're + # storing our state. + statedir = "/tmp" + # The cryptography module refuses to let us save the key without encrypting + # it with a non-empty password, so hardwire a password. + password = b"password" + # Create a lock under the state directory, because we may be about to + # rewrite some files. + lockfile = open(os.path.join(statedir, "ca.lock"), mode="wb") + fcntl.lockf(lockfile, fcntl.LOCK_EX) + + # Default to the "SUBMIT" operation if one isn't set, and if we're in + # "SUBMIT" mode and didn't get a CSR, try to read one from stdin. This + # isn't required by the daemon (it always sets the environment variable, + # and connects our stdin to /dev/null), but it makes manual testing and + # troubleshooting much, much easier. + if os.getenv(u"CERTMONGER_OPERATION", "") == "": + os.environ[u"CERTMONGER_OPERATION"] = u"SUBMIT" + if os.getenv(u"CERTMONGER_OPERATION", u"SUBMIT") == u"SUBMIT": + if os.getenv(u"CERTMONGER_CSR", "") == "": + os.environ[u"CERTMONGER_CSR"] = sys.stdin.read() + sys.stdin.close() + + # If the requested operation is one we support, do that. + if os.getenv(u"CERTMONGER_OPERATION", "") == "IDENTIFY": + sys.exit(identify()) + if os.getenv(u"CERTMONGER_OPERATION", "") == "SUBMIT": + sys.exit(submit(statedir, password)) + if os.getenv(u"CERTMONGER_OPERATION", "") == "FETCH-ROOTS": + sys.exit(fetch_roots(statedir, password)) + + # The requested operation is not something we support. + sys.exit(6) + +main() diff --git a/doc/helpers/proxy-submit.py b/doc/helpers/proxy-submit.py new file mode 100644 index 0000000..a6371b4 --- /dev/null +++ b/doc/helpers/proxy-submit.py @@ -0,0 +1,124 @@ +#!/usr/bin/python +""" + Basic certmonger helper, conforming to + https://git.fedorahosted.org/cgit/certmonger.git/tree/doc/helpers.txt + that calls a helper program, either locally or on another machine, + while optionally logging inputs and outputs. +""" +import json +import os +import subprocess +import sys +import syslog + +def remote(env, rsh, rcmd, log, priority): + """ + Utility function to use 'rsh' to run 'rcmd' remotely, with the values in the + 'env' dictionary set in the environment. If 'log' is True, it will log the + variables and stderr that it gets back at the specified 'priority'. Return the + exit status and stdout contents. + """ + # Encode the variables into a JSON document to make it easier to pass their + # values along. + envdoc = json.dumps(env, separators=(',', ':')) + if log: + for key in env.keys(): + syslog.syslog(priority, + "remote-submit: ENV: environment variable: %s=%s" % + (key, env[key])) + + # One-liner to decode a document, set variables, and then run the specified remote command. + rargs = rcmd.split() + script = "import json, os, sys; " + \ + "e = json.loads(sys.stdin.read()); " + \ + "[os.putenv(k,e[k]) for k in e]; " + \ + "os.execvp('%s',%s)" % (rargs[0], repr(rargs[:])) + + # Run that one liner remotely, and pipe the JSON document to its stdin. + # Whether we need to quote the one-liner or not depends on whether or not + # we're passing the command to rsh/ssh or just running it directly. + if len(rsh.split()) == 0: + quote = "" + else: + quote = "\"" + args = rsh.split() + ["python", "-c", quote+script+quote] + sub = subprocess.Popen(args, shell=False, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + close_fds=True) + stdout, stderr = sub.communicate(envdoc.encode('utf-8')) + while sub.returncode is None: + sub.wait() + + # Send back whatever the remote end gave us on stdout, and relay the exit + # status. The daemon called us with stdin and stderr connected to + # /dev/null, so there's no need to bother with either of them. + if log: + syslog.syslog(priority, "remote-submit: OUT: result exit status: %d" % sub.returncode) + if len(stdout) > 0: + syslog.syslog(priority, + "remote-submit: STDOUT: result data (%d bytes):\n%s" % + (len(stdout), stdout.decode('utf-8'))) + else: + syslog.syslog(priority, "remote-submit: STDOUT: (no result data)") + for line in stderr.decode('utf-8').split("\n"): + if len(line) > 0: + syslog.syslog(priority, "remote-submit: STDERR: %s" % line) + return sub.returncode, stdout + +def get_certmonger_vars(): + """ + Returns a dictionary of the environment variables that tell the helper + what's going on. By convention, the variables that are relevant to helpers + all start with CERTMONGER_, and this will continue to be the case as new + variables are added. + """ + env = {} + for key in os.environ.keys(): + if key.startswith("CERTMONGER_"): + env[key] = os.environ[key] + return env + +def main(): + """ + Wraps up the relevant environment variables in a JSON structure, uses a + remote shell to run a python one-liner that sets those variables in its + environment and then executes a specified binary, which we assume is a + certmonger helper, and relays back the binary's exit status and output. + + A certmonger helper expects all of its input data to be in the environment, + and communicates results using stdout and its exit status, so this is + enough to run a helper remotely. + + Configuration is hard-coded. + """ + # Configuration. Note that the 'rsh' command is run as root by certmonger, + # unattached to the context in which 'getcert' was run, so it can't prompt + # for passwords or pass phrases. Set 'rsh' to "" to run the helper + # locally. + rsh = "ssh centralbox" + rcmd = "/usr/libexec/certmonger/local-submit" + log = True + priority = syslog.LOG_INFO + + # Default to the "SUBMIT" operation if one isn't set, and if we're in + # "SUBMIT" mode and didn't get a CSR, try to read one from stdin. This + # isn't required by the daemon (it always sets the environment variable, + # and connects our stdin to /dev/null), but it makes manual troubleshooting + # much, much easier. + env = get_certmonger_vars() + if env.get("CERTMONGER_OPERATION") is None or env["CERTMONGER_OPERATION"] == "": + env["CERTMONGER_OPERATION"] = "SUBMIT" + if env["CERTMONGER_OPERATION"] == "SUBMIT": + if env.get("CERTMONGER_CSR") is None or env["CERTMONGER_CSR"] == "": + env["CERTMONGER_CSR"] = sys.stdin.read() + sys.stdin.close() + + # Run the helper remotely, passing it the variables that we care about, and + # relay its stdout and exit status. + (code, stdout) = remote(env, rsh, rcmd, log, priority) + sys.stdout.write(stdout.decode('utf-8')) + sys.exit(code) + +main()