Ottomata has uploaded a new change for review. (
https://gerrit.wikimedia.org/r/359960 )
Change subject: Initial commit of certpy
......................................................................
Initial commit of certpy
Bug: T166167
Change-Id: Ie1a251caa6eaafc3d53120eff85ddb5e7b77369c
---
A .gitignore
A .gitreview
A README.md
A certpy/__init__.py
A certpy/ca.py
A certpy/certificate.py
A certpy/config.py
A certpy/key.py
A certpy/main.py
A certpy/puppet_sign_cert.rb
A certpy/util.py
A examples/example.certs.yaml
A setup.py
13 files changed, 1,774 insertions(+), 0 deletions(-)
git pull ssh://gerrit.wikimedia.org:29418/operations/software/certpy
refs/changes/60/359960/1
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..09ee3be
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,68 @@
+
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+env/
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+parts/
+sdist/
+var/
+*.egg-info/
+.installed.cfg
+*.egg
+
+# PyInstaller
+# Usually these files are written by a python script from a template
+# before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*,cover
+.hypothesis/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+target/
+
+#Ipython Notebook
+.ipynb_checkpoints
+
+# pyenv
+.python-version
+
+venv
+virtualenv
diff --git a/.gitreview b/.gitreview
new file mode 100644
index 0000000..bf41f50
--- /dev/null
+++ b/.gitreview
@@ -0,0 +1,6 @@
+[gerrit]
+host=gerrit.wikimedia.org
+port=29418
+project=operations/software/certpy.git
+defaultbranch=master
+defaultrebase=0
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..ef377a4
--- /dev/null
+++ b/README.md
@@ -0,0 +1,156 @@
+# certpy
+
+Manages and generates OpenSSL key and certificate files declared in a YAML
manifest.
+
+Generation of the following file types is currently supported:
+
+- ```.key``` - OpenSSL private key in .pem format.
+- ```.crt``` - OpenSSL public key certificate in .pem format
+- ```.p12``` - PKCS#12 key store file.
+- ```.jks``` - Java keystore file.
+
+## Usage
+
+```
+certpy -h
+Reads in Certificate and CA manifest configuration and manages
+OpenSSL keys, certificates, and authorities in various formats and stores.
+
+Usage: certpy [options] <manifest_path>
+
+ <manifest_path> is the path to the certificate and authority manifest
config file(s).
+ If this is a directory, then all files that match
--manifest-glob
+ (default '*.certs.yaml') will be loaded as manifests.
+
+Options:
+ -h --help Show this help message and exit.
+ -d --working-dir cd to this directory before generating
anything.
+ This allows relative file paths in the
manifest to
+ be generated in a different location than the
current cwd.
+ -G --generate-certs Generate all certificate (excluding CA certs).
+ -A --generate-authorities Generate all CA certficiate files, if possible.
+ -F --force If given a generate option without --force,
any existing files will not
+ be overwritten. If want to overwrite files,
provide --force.
+ -v --verbose Turn on verbose debug logging.
+
+```
+
+certpy's CLI works with a YAML manifest. The manifest declares various
certificate and key
+parameters that are then used to instantiate model classes that can generate
key and
+certificate files.
+
+
+### certpy manifest .yaml
+
+The manifest yaml attempts to match the kwargs that can be used to instantiate
+various model classes. This allows new model subclasses to be created and
+instantiated with manfiest configuration without having to write code to
+handle the config -> code instantiation.
+
+A manifest can declare 2 top level configration keys, ```authorities``` and
```certs```
+
+#### ```certs```
+
+The ```certs``` config object should be a hash of certificate common names to
+certificate parameters.
+
+```
+certs:
+ # Common name of the certificate
+ hostname1.example.org:
+ # Directory where OpenSSL files will live
+ path: certificates/hostname1.example.org
+ # Name of the CA to use for this certificate. This must match
+ # A name of an authority in the authorities manifest. (optional)
+ ca: rootCa
+ # x509 subject
+ subject:
+ C: US
+ # DNS alternate names to put in the SAN (optional)
+ dns_alt_names: [example.org]
+ # Password to use for keystore files.
+ password: qwerty
+ # Key class configuration
+ key:
+ # Fully qualified (with module name if needed) class name to use for the
key.
+ type: ECKey
+ # Private key password. Optional. If not provided,
+ # the certificate's keystore password will be used.
+ password: qwerty
+
+ hostname2.example.org
+ ...
+````
+
+#### ```authorities```
+
+The ```authorities``` config object should be a hash of CA names to CA and CA
certificate
+parameters.
+
+```
+authorities:
+ # The name of this CA. This will be used as the CA cert's common name.
+ rootCa:
+ # Fully qualified (with module name if needed) class name to use for the
CA class.
+ # If not given, SelfSigningCA will be used. (TODO)
+ type: SelfSigningCA
+ # A Certificate config object, with the same structure used to declare
certs in
+ # the certificates config object.
+ cert:
+ path: .certificates/rootCa
+ subject:
+ C: US
+ password: qwerty
+ key:
+ type: RSAKey
+ password: qwerty
+ #TODO: document and test PuppetCA.
+ puppet:
+ type: PuppetCA
+```
+
+Note that some authorities here may not be generateable, and as such will not
need to declare
+the specifics of the CA certs.
+
+## certpy as module
+certpy can be used as a python library to model OpenSSL Keys, Certificates and
CAs. The
+underlying code does not depend on a Python implementation of OpenSSL, but
instead
+shells out to the openssl CLI and the Java keytool CLI in order to generate
and verify
+certificate files in different formats.
+
+There are 3 top level models:
+
+### ```Key```
+A ```Key``` class represents an OpenSSL private key. See docuemntation for
```certpy.key```
+for more information and instructions on how to implement more ```Key```
subclasses.
+
+Currently this package provides an ```RSAKey``` and an ```ECKey``` (elliptic
curve) ```Key``` implementations.
+
+### ```Certificate```
+A ```Certificate``` class represents OpenSSL certificate and files. It is
used to ensure that
+supported file formats exist, and to generate them if they don't.
```Certificate```
+is not meant to be subclassed.
+
+### ```CA```
+A ``CA`` class is an implementation of a Certificate Authority. It is meant
to wrap the
+``Certificate`` class, as often a ``CA`` has it's own key and certificate
files. However, the
+main purpose is to provide an interface for ```Certificate```s to generate CSR
+(Certificate Signing Requests) and have a CA sign and generate a signed public
```.crt```
+file. It is not necessary for ```CA``` subclasses to be given a
````Certfificate```` instance,
+but they will need to at least instantiate a ```self.ca_cert```
```Certificate``` instance
+themselves, even with dummy values, so that logging and status reporting of
certificate file existance works.
+
+Every ```CA``` subclass should implement the ```sign``` and ```verify```
methods, and if possible,
+a ```generate``` method.
+
+Currently this package provides a ```SelfSigningCA``` and ```PuppetCA```
implementations.
+
+#### ```SelfSigningCa```
+This ```CA``` uses its own Certificate instance to signing other Certificate
files.
+
+#### ```PuppetCA```
+This ```CA``` uses a puppet master CA instance to sign CSRs and generate
```.crt``` files.
+Because Puppet manages its own certificate files, usage of this ```CA``` must
be done on the
+same node as the puppet master CA, so that it can copy the Puppet generated
```.crt.pem``` file
+out of the Puppet CA paths into the ```Certificate```'s expected ```.crt```
file path.
+
diff --git a/certpy/__init__.py b/certpy/__init__.py
new file mode 100755
index 0000000..0427621
--- /dev/null
+++ b/certpy/__init__.py
@@ -0,0 +1,13 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+"""
+TODO: docs here
+"""
+
+from .ca import *
+from .certificate import *
+from .config import *
+from .key import *
+from .util import *
+from .main import *
diff --git a/certpy/ca.py b/certpy/ca.py
new file mode 100644
index 0000000..44d7d2b
--- /dev/null
+++ b/certpy/ca.py
@@ -0,0 +1,270 @@
+# -*- coding: utf-8 -*-
+
+"""
+A CA class needs to be able to:
+ 1. Sign a CSR and generate a new certificate file
+ 2. Verify the a certificate file is signed with itself
+
+If relevant, we also want a CA class to be able to support self generation,
+i.e. a self signing root CA.
+
+As such, a CA subclass class that can used by a Certificate should implement
the following
+3 methods: sign, verify, and optionally, generate. All subclasses must also
set
+self.cert to a Certificate instance, even if the Certificate files cannot be
+referenced or generated. (This is needed for comprehensive status printing.)
+
+ '''
+ Given a Certificate instance cert, with a existant .csr and .key file,
+ this signs the certificate, resulting in the creation of cert.crt_file.
+
+ :param cert Certificate
+ '''
+ def sign(self, cert):
+ ...
+
+ '''
+ Given a Certificate instance cert, this should verify that cert.crt_file
+ there is signed by this CA.
+
+ :param cert Certificate
+ :return boolean
+ '''
+ def verify(self, cert):
+ ...
+
+ '''
+ Implement this if for CAs that can generate themselves. If the CA cannot
generate
+ itself, the base CA class will raise a NotImplementedError.
+ Usually, generate will be implemented using a self signed Certificate
(CA-less)
+ instance given to your CA class' constructor.
+ '''
+ def generate(self):
+ ...
+
+
+"""
+
+
+from .key import Key, RSAKey
+from .certificate import Certificate, Subject, default_subject # TODO REMOVE
+from .util import openssl, run_command, mkdirs, get_class_logger
+
+import logging
+import os
+import tempfile
+
+__all__ = ('CA', 'SelfSigningCA', 'PuppetCA')
+
+
+class CA(object):
+ """
+ Base 'abstract' class for CAs. You should implement these following
methods
+ in subclass CAs classes.
+ """
+ def __init__(self, **kwargs):
+ """
+ Constructor. All CA subclass construtors should take **kwargs and
+ should all set self.name.
+ """
+ raise NotImplementedError('__init__ not implemented for ' + str(self))
+
+ def generate(self, force=False):
+ raise NotImplementedError('generate not implemented for ' + str(self))
+
+ def sign(self, cert):
+ raise NotImplementedError('sign not implemented for ' + str(self))
+
+ def verify(self, cert):
+ raise NotImplementedError('verify not implemented for ' + str(self))
+
+ def __repr__(self):
+ if hasattr(self, 'name'):
+ return '{}({})'.format(self.__class__.__name__, self.name)
+ else:
+ return self.__class__.__name__
+
+
+class SelfSigningCA(CA):
+ """
+ A SelfSigningCA uses a self-signed (CA-less) Certificate to sign
+ other Certificates.
+ """
+ def __init__(self, cert, **kwargs):
+ """
+ :param cert CA Certificate instance. This should
+ represent the CA's key and certificate files.
+ """
+ self.ca_cert = cert
+ self.name = self.ca_cert.name
+ self.log = get_class_logger(self)
+
+
+ def sign(self, cert):
+ """
+ Signs a Certificate instance with this CA's certificate. This requires
+ that cert.csr_file has been generated and exists, as it will be used
when
+ signing the new certificate.
+
+ :param cert Certificate instance to sign.
+ """
+ command = [
+ openssl,
+ 'x509',
+ '-req',
+ '-CAcreateserial',
+ '-CA', self.ca_cert.crt_file,
+ '-CAkey', self.ca_cert.key.key_file,
+ '-in', cert.csr_file,
+ '-out', cert.crt_file
+ ]
+ if cert.digest:
+ command += ['-{}'.format(cert.digest)]
+ if cert.expiry_days:
+ command += ['-days', str(cert.expiry_days)]
+ # If this certificate's CSR was created with a CSR config file,
+ # then we should also pass the same config file when signing.
+ # This ensure that the requested SANs are included in the signed
+ # certificate.
+ if cert.csr_conf_file:
+ # TODO: perhaps somehow parameterize what extensions to use?
+ command += ['-extfile', cert.csr_conf_file]
+ if cert.dns_alt_names:
+ command+= ['-extensions', 'v3_req']
+
+ self.log.info('Signing CSR from {} with {}'.format(cert.csr_file,
self))
+ if not run_command(command, creates=cert.crt_file):
+ raise RuntimeError('Signing CSR {} failed'.format(cert.csr_file),
self)
+
+
+ def verify(self, cert):
+ """
+ Verifies that cert was signed with this CA.
+
+ :param cert Certificate instance to verify
+ :return boolean
+ """
+ # Verify that this CA was used to sign cert.crt_file.
+ command = [
+ openssl,
+ 'verify',
+ '-CAfile', self.ca_cert.crt_file,
+ cert.crt_file
+ ]
+ return run_command(command)
+
+
+ def generate(self, force=False):
+ """
+ Generates this CA's key and certificate files by calling
ca_cert.genearte().
+
+ :param force If true, files will be re-generated even if they
already exist.
+ """
+ self.log.info('Generating CA certificate')
+ return self.ca_cert.generate(force=force)
+
+ def __repr__(self):
+ return '{}(key_file={}, crt_file={})'.format(
+ self.__class__.__name__, self.ca_cert.key.key_file,
self.ca_cert.crt_file
+ )
+
+
+
+# TODO: This has not been tested!!!
+class PuppetCA(CA):
+ """
+ A PuppetCA signs and generates a new Certificate using the Puppet CA HTTP
API and CLI.
+ It does this by shelling out to a custom ruby script that imports and
extends
+ Puppet internals to support wildcard certificates. This CA cannot be
generated, as
+ it requires an already configured Puppet CA. This class can only be used
on
+ the same node where the Puppet CA lives, as it will attempt to copy the
certificate file
+ that Puppet CA generates into cert.crt_file.
+
+ :param puppet_hostname HTTP hostname for Puppet API
+ :param puppet_port HTTP port for Puppet API
+ :param puppet_ca_path Path where Puppet CA stores its certificate
files.
+ :param puppet_sign_script Path of custom ruby script to use for signing
certificates with Puppet CA
+ """
+ def __init__(
+ self,
+ puppet_hostname='puppet',
+ puppet_port=8140,
+ puppet_ca_path='/var/lib/puppet/server/ssl/ca',
+ puppet_sign_script='/usr/local/bin/puppet-sign-cert',
+ **kwargs
+ ):
+
+ self.name = '{}:{}'.format(puppet_hostname, puppet_port)
+ self.log = get_class_logger(self)
+
+ self.puppet_hostname = puppet_hostname
+ self.puppet_port = puppet_port
+ self.puppet_ca_path = puppet_ca_path
+ self.puppet_sign_script = puppet_sign_script
+
+ # Instantiate a ca_cert that will be used when a cert
+ # needs to reference its CA certificate. This is needed
+ # by Certificates where ca is a PuppetCA in order to
+ # generate p12 and jks files that include the Puppet CA certificate.
+ self.ca_cert = Certificate(
+ self.name,
+ self.puppet_ca_path,
+ # Dummy password and subject.
+ password=None,
+ subject={},
+ # read_only = True ensures that no accidental call
+ # to generate() on this CA cert will ever run.
+ read_only = True
+ )
+ # Puppet CA stores certs with different filenames than our Certificate
convention,
+ # so set them to what they should be.
+ # TODO: we could do this by create a PuppetCACertificate subclass of
Certificate. Hm.
+ self.ca_cert.key_file = os.path.join(self.puppet_ca_path, 'ca_key.pem')
+ self.ca_cert.crt_file = os.path.join(self.puppet_ca_path, 'ca_crt.pem')
+
+
+ def sign(self, cert):
+ """
+ Signs a Certificate instance using Puppet CA. The generated signed
certificate file
+ will be copied out of puppet_cert_path to cert.crt_file. This requires
+ that cert.csr_file has been generated and exists, as it will be used
when
+ signing the new certificate.
+
+ :param cert Certificate instance
+ """
+ # TODO verify that cert.crt_file exists
+
+ # call out to puppet_sign_cert.rb and then copy file from puppetmaster
dirs into base_path
+ command = [
+ self.puppet_sign_script,
+ '-H', self.puppet_hostname,
+ '-P', str(self.puppet_port),
+ cert.csr_file
+ ]
+ self.log.info('Submitting CSR {} to Puppet CA'.format(cert.csr_file))
+
+ if not run_command(command):
+ raise RuntimeError('Signing CSR {} with Puppet CA
failed'.format(cert.csr_file), self)
+
+
+ # Path to the signed certificate file that puppet will generate.
+ puppet_crt_file = os.path.join(self.puppet_cert_path, 'signed',
'{}.pem'.format(cert.name))
+ if not os.path.exists(puppet_crt_file):
+ raise RuntimeError(
+ 'Signing CSR {} with Puppet CA succeeded, but {} does not
exist. '
+ 'This should not happen.'.format(cert.csr_file,
puppet_crt_file),
+ self
+ )
+
+ # COPY new puppet .crt (.pem) file into crt_file location
+ mkdirs(cert.path)
+ self.log.info('Copying signed certificate from {} to {}'.format(
+ puppet_crt_file, cert.crt_file
+ ))
+ shutil.copyfile(puppet_crt_file, cert.crt_file)
+ if not os.path.exists(cert.crt_file):
+ raise RuntimeError(
+ 'Copying signed certificate from {} to {} failed'.format(
+ cert.csr_file, puppet_crt_file
+ ),
+ self
+ )
diff --git a/certpy/certificate.py b/certpy/certificate.py
new file mode 100644
index 0000000..c4f75ba
--- /dev/null
+++ b/certpy/certificate.py
@@ -0,0 +1,449 @@
+# -*- coding: utf-8 -*-
+
+from datetime import datetime
+import os
+import logging
+import shutil
+
+from .key import RSAKey
+from .util import openssl, keytool, run_command, mkdirs, get_class_logger,
is_in_keystore
+
+subject_fields = ['C', 'ST', 'O', 'OU', 'DN', 'CN', 'L', 'SN', 'GN']
+
+__all__ = ('Certificate', 'Subject', 'SubjectKeyError')
+
+
+class SubjectKeyError(KeyError):
+ def __init__(self, key):
+ super().__init__('{} is not a valid x509 subject field, must be one of
{}'.format(
+ key, ', '.join(subject_fields)
+ ))
+
+# TODO: support long names? mehhhhhh. use named tuple?? or UserDict
+# TODO: This needs much cleanup. WIP
+class Subject(dict):
+ @staticmethod
+ def factory(d):
+ subject_dict = {k.upper(): v for k,v in d.items()}
+ for k in subject_dict.keys():
+ if k not in subject_fields:
+ raise SubjectKeyError(k)
+
+ return Subject(subject_dict)
+
+ def openssl_string(self):
+ a = ['{}={}'.format(k.upper(), v) for k, v in self.items()]
+ return '/' + '/'.join(a) + '/'
+
+ def keytool_string(self):
+ a = ['{}={}'.format(k.lower(), v) for k, v in self.items()]
+ return ', '.join(a)
+
+
+
+# TODO: remove
+default_subject = Subject.factory({
+ 'O': 'WMF',
+ 'C': 'US',
+})
+
+
+
+csr_config_template = """
+[ req ]
+distinguished_name = req_distinguished_name
+{req_extensions}
+
+[ req_distinguished_name ]
+countryName = Country Name (2 letter code)
+stateOrProvinceName = State or Province Name (full name)
+localityName = Locality Name (eg, city)
+organizationName = Organization Name (eg, company)
+commonName = Common Name (e.g. server FQDN or YOUR name)
+"""
+
+san_config_template = """
+[ v3_req ]
+subjectAltName = @alt_names
+
+[ alt_names ]
+{alt_names}
+"""
+
+dns_alt_name_template = 'DNS.{i} = {alt_name}'
+
+def render_csr_config(dns_alt_names=None):
+ req_extensions = ''
+ alt_names = ''
+ if dns_alt_names:
+ req_extensions = 'req_extensions = v3_req'
+ for i, name in enumerate(dns_alt_names):
+ alt_names += dns_alt_name_template.format(i=i+1, alt_name=name) +
'\n'
+
+
+ content = csr_config_template.format(req_extensions=req_extensions)
+ if dns_alt_names:
+ content += san_config_template.format(alt_names=alt_names)
+
+ return content
+
+
+
+
+class Certificate(object):
+ """
+ Represents an OpenSSL certificate. Handles generation of
+ subject is a dict mapping x509 subject keys to values.
+ """
+ def __init__(
+ self,
+ name,
+ path,
+ password,
+ key=None,
+ subject=default_subject,
+ dns_alt_names=None,
+ expiry_days=None,
+ digest=None,
+ ca=None,
+ read_only=False
+ ):
+ self.name = name
+ self.path = os.path.abspath(path)
+ self.key = key
+
+ subject['CN'] = name # TODO: ???
+ self.subject = Subject.factory(subject)
+ self.dns_alt_names = dns_alt_names
+
+ self.expiry_days = expiry_days
+ self.password = password
+
+ # TODO validate that digest is supported (e.g. sha256)
+ self.digest = digest
+
+ # Verify that CA has required methods. ( duck typing :) )
+ if ca and (
+ not hasattr(ca, 'sign') or
+ not hasattr(ca, 'verify')
+ ):
+ raise RuntimeError(
+ 'Cannot instante new Certificate. ca {} should implement '
+ 'both sign and verify methods.'.format(ca),
+ self
+ )
+
+ self.ca = ca
+
+ self.read_only = read_only
+
+ # If not give a key, then create a new RSA key by default. TODO: keep
this?
+ if key:
+ self.key = key
+ else:
+ self.key = RSAKey(name, path, password)
+
+ # Private Key in .pem format
+ self.key_file = self.key.key_file
+ # Certificate Signing Request
+ self.csr_file = os.path.join(self.path, '{}.csr'.format(self.name))
+ # CSR config file. Needed to support SANs.
+ self.csr_conf_file = os.path.join(self.path,
'{}.csr.cnf'.format(self.name))
+ # Public Signed Certificate in .pem format
+ self.crt_file = os.path.join(self.path, '{}.crt'.format(self.name))
+
+ # PKCS#12 'keystore' file
+ self.p12_file = os.path.join(self.path, '{}.p12'.format(self.name))
+ # Java Keystore
+ self.jks_file = os.path.join(self.path, '{}.jks'.format(self.name))
+
+ self.log = get_class_logger(self)
+
+ # TODO: rename this since we are removing paths, and clean up conditional
logic.
+ def should_generate(self, path=None, force=False):
+ should_generate = True
+ if self.read_only:
+ self.log.warn(
+ 'Cannot call any generate method on a read_only Certificate.
Skipping generation.'
+ )
+ should_generate = False
+ elif path and os.path.exists(path):
+ if force:
+ self.log.warn(
+ '{} exists, but force is True. Removing before '
+ 'continuing with generation.'.format(path)
+ )
+ if os.path.isdir(path):
+ shutil.rmtree(path)
+ else:
+ os.remove(path)
+ should_generate = True
+ else:
+ self.log.warn(
+ '{} exists, skipping generation.'.format(path)
+ )
+ should_generate = False
+ else:
+ should_generate = True
+
+ return should_generate
+
+ def generate(self, force=False):
+ # This top level should_generate check looks for the existence of
self.path.
+ # It will not even attempt to recreate the files if self.path exists
and force=False.
+ if not self.should_generate(self.path, force):
+ return False
+
+ self.log.info('Generating all files...')
+ mkdirs(self.path)
+
+ self.key.generate(force=force)
+ self.generate_crt(force=force)
+ self.generate_p12(force=force)
+ self.generate_keystore(force=force)
+
+ return True
+
+ def generate_crt(self, force=False):
+ if not self.should_generate(self.crt_file, force):
+ return False
+
+ # If we are going to include DNS alt names in the cert,
+ # we'll need a CSR conf file that specifies them. For consistency,
+ # generate this conf file even if there are no DNS alt names specified.
+ self._generate_csr_conf(force=force)
+
+ # If no ca was provided, then generate a self signed certificate
+ if not self.ca:
+ self._self_generate_crt(force=force)
+ else:
+ self._ca_generate_crt(force=force)
+
+
+ def _self_generate_crt(self, force=False):
+ if not self.should_generate(self.crt_file, force):
+ return False
+
+ # Generate the certificate without a ca
+ command = [
+ openssl,
+ 'req',
+ '-x509',
+ '-new',
+ '-config', self.csr_conf_file,
+ '-subj', self.subject.openssl_string(),
+ '-key', self.key.key_file,
+ '-out', self.crt_file
+ ]
+ if self.digest:
+ command += ['-{}'.format(self.digest)]
+ if self.expiry_days:
+ command =+ ['-days', str(self.expiry_days)]
+ if self.key.password:
+ command += ['-passin', 'pass:{}'.format(self.key.password)]
+ # If we need to instruct the x509 cert to use our custom SAN
+ # extensions section in the conf file.
+ if self.dns_alt_names:
+ command += ['-extensions', 'v3_req']
+
+ self.log.info('Generating self signed certificate')
+ if not run_command(command, creates=self.crt_file):
+ raise RuntimeError('Certificate generation failed', self)
+
+ return True
+
+ def _ca_generate_crt(self, force=False):
+ if not self.should_generate(self.crt_file, force):
+ return False
+
+ self.generate_csr(force=force)
+ self.log.info('Sending CSR to {}'.format(self.ca))
+ self.ca.sign(self)
+
+ # Verify that crt_file was created by the CA when it signed CSR.
+ if not os.path.exists(self.crt_file):
+ raise RuntimeError(
+ '{} does not exist even though {} signed and generated a '
+ 'certificate. This should not happen'.format(self.crt_file,
self.ca)
+ )
+
+ self.log.info('Verifying signed certificate with {}'.format(self.ca))
+ if not self.ca.verify(self):
+ raise RuntimeError('Certificate {} verification failed with
{}'.format(
+ self.crt_file, self.ca
+ ))
+
+ return True
+
+ def generate_csr(self, force=False):
+ if not self.should_generate(self.csr_file, force):
+ return False
+
+ # In order to support adding SANs to the CSR,
+ # we need to use a config file. To be consistent, we generate
+ # and use this config file, event if we don't have any SANS
+ self._generate_csr_conf(force=force)
+
+ command = [
+ openssl,
+ 'req',
+ '-new',
+ '-config', self.csr_conf_file,
+ '-subj', self.subject.openssl_string(),
+ '-key', self.key.key_file,
+ '-out', self.csr_file
+ ]
+ if self.digest:
+ command += ['-{}'.format(self.digest)]
+ if self.key.password:
+ command += ['-passin', 'pass:{}'.format(self.key.password)]
+
+ self.log.info('Generating CSR')
+ if not run_command(command, creates=self.csr_file):
+ raise RuntimeError('CSR generation failed', self)
+
+ return True
+
+ def _generate_csr_conf(self, force=False):
+ if not self.should_generate(self.csr_conf_file, force):
+ return False
+
+ csr_config_content = render_csr_config(self.dns_alt_names)
+ with open(self.csr_conf_file, 'w') as f:
+ f.write(csr_config_content)
+ f.flush()
+ if not os.path.exists(self.csr_conf_file):
+ raise RuntimeError(
+ 'Attempted to write CSR conf file {}, but it does not exist. '
+ 'This should not happen.'.format(self.csr_conf_file),
+ self
+ )
+ return True
+
+
+ def generate_p12(self, force=False):
+ if not self.should_generate(self.p12_file, force):
+ return False
+
+ command = [
+ openssl,
+ 'pkcs12',
+ '-export',
+ '-name', self.name,
+ # private key
+ '-inkey', self.key.key_file,
+ # Public certificate
+ '-in', self.crt_file,
+ # output p12 keystore with password
+ '-passout', 'pass:{}'.format(self.password),
+ '-out', self.p12_file,
+ ]
+ if self.key.password:
+ command += ['-passin', 'pass:{}'.format(self.key.password)]
+
+ if self.ca:
+ # TODO: This will not work with PuppetCA!
+ command += ['-CAfile', self.ca.ca_cert.crt_file]
+
+ self.log.info('Generating PKCS12 keystore')
+ if not run_command(command, creates=self.p12_file):
+ raise RuntimeError('PKCS12 file generation failed', self)
+
+ # Verify that the cert is in the P12 file.
+ if not is_in_keystore(self.name, self.p12_file, self.password):
+ raise RuntimeError(
+ 'Generation of PKS12 keystore succeeded, but a key for '
+ '{} is not in {}. This should not happen'.format(
+ self.name, self.jks_file
+ )
+ )
+
+ # TODO: do we need to import the ca_cert into the PKS12 keystore too?
+
+ return True
+
+ def generate_keystore(self, force=False):
+ if not self.should_generate(self.jks_file, force):
+ return False
+
+ command = [
+ keytool,
+ '-importkeystore',
+ '-noprompt',
+ '-alias', self.name,
+ '-srcstoretype', 'PKCS12',
+ '-srcstorepass', self.password,
+ '-srckeystore', self.p12_file,
+ '-deststorepass', self.password,
+ '-destkeystore', self.jks_file
+ ]
+ if self.key.password:
+ command += ['-srckeypass', self.key.password, '-destkeypass',
self.key.password]
+
+ self.log.info('Generating Java keystore')
+ if not run_command(command, creates=self.jks_file):
+ raise RuntimeError(
+ 'Java Keystore generation and import of certificate failed',
self
+ )
+
+ # Verify that the cert is in the Java Keystore.
+ if not is_in_keystore(self.name, self.jks_file, self.password):
+ raise RuntimeError(
+ 'Java Keystore generation and import of certificate '
+ 'succeeded, but a key for {} is not in {}. This should not
happen'.format(
+ self.name, self.jks_file
+ )
+ )
+
+ # If this certificate was signed by a CA, then also
+ # import the CA certificate into the keystore.
+ if self.ca:
+ command = [
+ keytool,
+ '-importcert',
+ '-noprompt',
+ "-alias", self.ca.ca_cert.name,
+ '-file', self.ca.ca_cert.crt_file,
+ '-storepass', self.password,
+ '-keystore', self.jks_file
+ ]
+ self.log.info('Importing {} cert into Java
keystore'.format(self.ca))
+ if not run_command(command):
+ raise RuntimeError(
+ 'Import of {} cert into Java Keystore
failed'.format(self.ca), self
+ )
+ # Verify that the ca_cert is in the Java Keystore.
+ if not is_in_keystore(self.ca.ca_cert.name, self.jks_file,
self.password):
+ raise RuntimeError(
+ 'Import of {} certificate into Java Keystore succeeded,
but a key for '
+ '{} is not in {}. This should not happen'.format(
+ self.ca, self.ca.cert.name, self.jks_file
+ )
+ )
+
+ return True
+
+ def __repr__(self):
+ if self.ca:
+ return '{}(name={}, keytype={}, ca={})'.format(
+ self.__class__.__name__, self.name,
+ self.key.__class__.__name__, self.ca.name
+ )
+ else:
+ return '{}(name={}, keytype={})'.format(
+ self.__class__.__name__, self.name,
+ self.key.__class__.__name__
+ )
+
+
+ def status_string(self):
+ file_statuses = []
+ for p in [self.key_file, self.crt_file, self.p12_file, self.jks_file]:
+ if os.path.exists(p):
+ mtime = datetime.fromtimestamp(os.path.getmtime(p)).isoformat()
+ file_statuses += ['\t{}: PRESENT (mtime: {})'.format(p, mtime)]
+
+ else:
+ file_statuses += ['\t{}: ABSENT'.format(p)]
+
+ return '{}:\n{}'.format(self, '\n'.join(file_statuses))
diff --git a/certpy/config.py b/certpy/config.py
new file mode 100644
index 0000000..d09e3bf
--- /dev/null
+++ b/certpy/config.py
@@ -0,0 +1,221 @@
+# -*- coding: utf-8 -*-
+
+import os
+import logging
+import importlib
+from yamlreader import yaml_load
+
+# TODO make new classes loadable from plugins
+from .certificate import *
+from .key import *
+from .ca import *
+
+__all__ = ('load_manifests', 'instantiate_manifest') # TODO: validate
+
+# TODO: use relative paths in config???
+
+default_config = {
+ 'authorities': {},
+ 'certs': {}
+}
+
+log = logging.getLogger('config')
+
+def load_manifests(path, glob='*.certs.yaml'):
+ """
+ Given a directory, this will load all files matching config_file_glob
+ as YAML and then recursively merge them into a single config hash
+ using the yamlreader library.
+
+ :param glob default: '*.certs.yaml'
+ :return config dict
+ """
+ path = os.path.abspath(path)
+
+ # If this is a single file, then no need to use the glob.
+ if os.path.isfile(path):
+ log.info('Loading certificate and authority manifest from
{}'.format(path))
+ return yaml_load(path, default_config)
+ # Else it is a directory, probably containing multiple manifest files.
+ # Load any file that matches path glob.
+ else:
+ path_glob = os.path.join(path, glob)
+ log.info('Loading all certificate and authority manifests in
{}'.format(path_glob))
+ return yaml_load(path_glob, default_config)
+
+
+ # TODO: validate loaded manifest config against jsonschema
+
+
+def instantiate_manifest(manifest):
+ """
+ Given a certificate maniest dict object (usually loaded from a yaml file),
containing
+ definitions of certificates and CAs to manage, instantiate them into
objects.
+
+ :param manifest manifest of all authority and certificate configs. CAs,
Certificate, and Key
+ classes will all be instantiated based on this
configuration and returned.
+ This can also be a string path to manifests that will be
loaded
+ using load_manifests.
+
+ :return dict of the form { 'authorities': {...}, 'certificates': {...}
}, where each
+ declared CA and Certificate will be keyed by name in the
appropriate dict.
+ """
+
+ if isinstance(manifest, str):
+ manifest = load_manifests(manifest)
+
+ authorities = instantiate_authorities(manifest['authorities'])
+ certificates = instantiate_certs(manifest['certs'], authorities)
+
+ return {
+ 'authorities': authorities,
+ 'certificates': certificates
+ }
+
+
+
+def instantiate_cert(cert_config, authorities={}):
+ """
+ Given a cert config manifest, instantiate the Certificate class and return
it.
+ If this cert has a CA configured, that CA should already be instantiated in
+ the authorities hash, keyed by the CA named in the cert_config.
+
+ :param certs_manifest cert manifest config keyed by name to instantiate.
+ Make sure that cert_config['name'] is set.
+ :param authorities dict of CA instances keyed by ca name.
+
+
+ :return Certificate
+ """
+ # If we have a special config for this key (that is not the default RSAKey)
+ # that Certificate will generate if not given a key, then we need to
+ # instantiate a new key now.
+ if 'key' in cert_config:
+ key_config = cert_config['key']
+ key_config.setdefault('name', cert_config['name'])
+ key_config.setdefault('path', cert_config['path'])
+ key_config.setdefault('password', cert_config['password'])
+
+ cert_config['key'] = instantiate(key_config['type'], **key_config)
+
+ if 'ca' in cert_config:
+ ca_name = cert_config['ca']
+
+ if ca_name not in authorities:
+ raise RuntimeError(
+ '{name} cert\'s CA is set to {ca}, but authority with '
+ 'name {ca} is not declared in authorities.'.format(
+ name=name, ca=ca_name
+ ))
+
+ cert_config['ca'] = authorities[ca_name]
+
+ return Certificate(**cert_config)
+
+def instantiate_certs(certs_manifest, authorities):
+ """
+ This will return a dict of Certficate instances keyed by name
+ representing all of the certs declared in certs_manifest.
+ If a cert declares a ca, make sure that a CA instance exists
+ in the authorities dict, keyed by the same name as the ca
+ that the cert config specifies.
+
+ :param certs_manifest dict of cert manifest config keyed by name to
instantiate.
+ :param authorities dict of CA instances keyed by ca name.
+
+ :return dict dict of Certificate instances keyed by name.
+ """
+ certs = {}
+ for name, cert_config in certs_manifest.items():
+ # Set cert_config['name'] to this manifest cert key name.
+ cert_config['name'] = name
+
+ cert = instantiate_cert(cert_config, authorities)
+ certs[name] = cert
+ return certs
+
+
+# TODO change param to authority_config
+def instantiate_authorities(authorities_manifest):
+ """
+ Given a manifest of authority config, this will instantiate each as a CA
instance.
+
+ :param authorities_manifest dict of CA config keyed by CA name.
+ :return dict dict of CA instances keyed by name.
+ """
+ authorities = {}
+ # TODO: s/ca_config/authority_config?
+ for name, ca_config in authorities_manifest.items():
+ ca_config['name'] = name
+
+ print(ca_config)
+ # Instantiate the CA cert for this CA IF it has one
+ if 'cert' in ca_config:
+ ca_cert_config = ca_config['cert']
+ ca_cert_config['name'] = name
+ ca_cert = instantiate_cert(ca_cert_config)
+ ca_config['cert'] = ca_cert
+
+ ca = instantiate(ca_config['type'], **ca_config)
+ authorities[name] = ca
+
+ return authorities
+
+
+def validate(config):
+ pass
+ # TODO: json schema? Maybe!
+
+
+
+def get_class(module_class_name):
+ """
+ Returns module_class_name as a class. This is useful for instantiating
+ a class by its dotted string name.
+
+ :param module_class_name Fully qualified name, e.g. 'my.module.ClassName'.
This must
+ either be in globals(), or be importable via
importlib.import_module
+
+ :return Class
+ """
+ if module_class_name in globals():
+ return globals()[module_class_name]
+ elif module_class_name in locals():
+ return locals()[module_class_name]
+ elif '.' in module_class_name:
+ module_name, class_name = module_class_name.rsplit('.', 1)
+ module = importlib.import_module(module_name)
+ return getattr(module, class_name)
+ else:
+ raise RuntimeError(
+ 'Cannot dynamically import {}, '
+ 'it is not in globals() or importable'.format(module_class_name)
+ )
+
+
+def instantiate(class_name, **kwargs):
+ """
+ Given a fully qualified module and class name, this returns a new
+ instance of the class with kwargs passed to the constructor.
+
+ Make sure that the fully qualified class name that you pass is in
+ PYTHONPATH. Example:
+
+ $ tree /path/to/certpy_ext
+ /path/to/certpy_ext/
+ └── ext
+ └── key.py
+
+ $ head -n 2 /path/to/certpy_ext/ext/key.py
+ from certpy import Key
+ class DSAKey(Key):
+
+ $ export PYTHONPATH=/path/to/certpy_ext
+
+ ...
+ instantiate('ext.key.DSAKey')
+
+ :param class_name fully qualified class name
+ :return instance of class
+ """
+ return get_class(class_name)(**kwargs)
diff --git a/certpy/key.py b/certpy/key.py
new file mode 100644
index 0000000..37e3a10
--- /dev/null
+++ b/certpy/key.py
@@ -0,0 +1,154 @@
+# -*- coding: utf-8 -*-
+
+"""
+Classes for OpenSSL private key generation. Extend the base Key class if you
need
+to generate a key of a type not yet supported here.
+"""
+
+import os.path
+
+from .util import mkdirs, run_command, openssl, get_class_logger
+
+__all__ = ('Key', 'RSAKey', 'ECKey')
+
+
+class Key(object):
+ """
+ Base class for Key objects. This just sets up common instance variables
+ and includes some convenience methods.
+ """
+ def __init__(self, name, path, password=None, **kwargs):
+ """
+ :param name
+ :param path path in which to look for and generate files
+ :param password key password
+ """
+
+ self.name = name
+ self.path = os.path.abspath(path)
+ self.password = password
+ # Private Key file in .pem format
+ self.key_file = os.path.join(self.path, '{}.key'.format(self.name))
+
+ self.log = get_class_logger(self)
+
+ def exists(self):
+ """
+ Checks that this Key exists at the expected key file path.
+ """
+ return os.path.exists(self.key_file)
+
+ def check_force_generate(self, force):
+ """
+ DRY method for checking if generate should be allowed even if the key
file already exists.
+ :param force If True, this returns True
+ :return True or False if generation should be allowed
+ """
+ if self.exists() and not force:
+ self.log.warn(
+ '{} already exists, skipping key
generation...'.format(self.key_file)
+ )
+ return False
+ else:
+ return True
+
+ def generate(self, force=False):
+ """
+ Implement this method to support generation of your Key subclass.
+ """
+ raise NotImplementedError(
+ 'Cannot generate Key of unknown algorithm type. Use a subclass.',
self
+ )
+
+ def __repr__(self):
+ return '{}({})'.format(self.__class__.__name__, self.name)
+
+
+
+class RSAKey(Key):
+
+ def __init__(self, name, path, password=None, key_size=2048, **kwargs):
+ """
+ Helps with generation of RSA key files.
+ :param name
+ :param path path in which to look for and generate files
+ :param password key password
+ :param key_size RSA key size
+ """
+ self.key_size = key_size
+ super().__init__(name, path, password)
+
+ def generate(self, force=False):
+ """
+ Generates the key file.
+ :param force if True, the key will be re-generated even if the key
file exists.
+ """
+ if not self.check_force_generate(force):
+ return False
+
+ mkdirs(self.path)
+
+ command = [openssl, 'genrsa', '-out', self.key_file]
+ if self.password:
+ command += ['-passout', 'pass:{}'.format(self.password)]
+ command += [str(self.key_size)]
+
+ self.log.info('Generating RSA key')
+ if not run_command(command):
+ raise RuntimeError('RSA key generation failed')
+
+ if not self.exists():
+ raise RuntimeError(
+ 'Key generation succeeded but key file does not exist. '
+ 'This should not happen', self
+ )
+
+
+class ECKey(Key):
+ """
+ Helps with generation of Eliptic Curve key files.
+ :param name
+ :param path path in which to look for and generate files
+ :param password key password
+ :param asn1_oid
+ """
+ def __init__(self, name, path, password=None, asn1_oid='prime256v1',
**kwargs):
+ self.asn1_oid = asn1_oid
+ super().__init__(name, path, password)
+
+ def generate(self, force=False):
+ """
+ Generates the key file.
+ :param force if True, the key will be re-generated even if the key
file exists.
+ """
+ if not self.check_force_generate(force):
+ return False
+
+ mkdirs(self.path)
+
+ command = [openssl, 'ecparam', '-genkey', '-name', self.asn1_oid,
'-out', self.key_file]
+
+ self.log.info('Generating EC key')
+ # Generate the keyfile with no password
+ if not run_command(command):
+ raise RuntimeError('EC key generation failed', self)
+
+ # Now encrypt the key with a password, overwriting the original
+ # passwordless key.
+ if self.password:
+ command = [
+ openssl, 'ec',
+ '-in', self.key_file,
+ '-out', self.key_file,
+ '-des3', '-passout', 'pass:{}'.format(self.password)
+ ]
+ self.log.info('Encrypting key with password')
+
+ if not run_command(command):
+ raise RuntimeError('EC key file password encryption failed')
+
+ if not self.exists():
+ raise RuntimeError(
+ 'Key generation succeeded but key file does not exist. '
+ 'This should not happen', self
+ )
diff --git a/certpy/main.py b/certpy/main.py
new file mode 100755
index 0000000..46e1106
--- /dev/null
+++ b/certpy/main.py
@@ -0,0 +1,96 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+"""
+Reads in Certificate and CA manifest configuration and manages
+OpenSSL keys, certificates, and authorities in various formats and stores.
+
+Usage: certpy [options] <manifest_path>
+
+ <manifest_path> is the path to the certificate and authority manifest
config file(s).
+ If this is a directory, then all files that match
--manifest-glob
+ (default '*.certs.yaml') will be loaded as manifests.
+
+Options:
+ -h --help Show this help message and exit.
+ -d --working-dir cd to this directory before generating
anything.
+ This allows relative file paths in the
manifest to
+ be generated in a different location than the
current cwd.
+ -G --generate-certs Generate all certificate (excluding CA certs).
+ -A --generate-authorities Generate all CA certficiate files.
+ -F --force If given a generate option without --force,
any existing files will not
+ be overwritten. If want to overwrite files,
provide --force.
+ -v --verbose Turn on verbose debug logging.
+"""
+
+from docopt import docopt
+
+from pprint import pprint # TODO: remove
+
+from certpy import instantiate_manifest, setup_logging
+
+
+import logging
+
+log = logging.getLogger('certpy')
+
+
+def authorities_status_string(authorities):
+ s = '--- Authorities ---\n'
+ for name, ca in authorities.items():
+ s += '{}({}):\n'.format(ca.__class__.__name__, ca.name)
+ s += '{}\n'.format(ca.ca_cert.status_string())
+ return s
+
+
+def certificates_status_string(certificates):
+ s = '--- Certificates ---\n'
+ for name, cert in certificates.items():
+ s += '{}\n'.format(cert.status_string())
+ return s
+
+
+def generate_authorities(authorities, force=False):
+ # generate all authorities
+ for name, ca in authorities.items():
+ if hasattr(ca, 'generate'):
+ try:
+ ca.generate(force=force)
+ except NotImplementedError as e:
+ logging.warn('{} does not support generation,
skipping.'.format(ca))
+
+def generate_certificates(certificates, force=False):
+ for name, cert in certificates.items():
+ cert.generate(force=force)
+
+def main():
+ # parse arguments with docopt
+ args = docopt(__doc__)
+
+ setup_logging()
+
+ loaded_manifest = instantiate_manifest(args['<manifest_path>'])
+ authorities = loaded_manifest['authorities']
+ certificates = loaded_manifest['certificates']
+
+
+ # TODO: implement --working-dir
+
+ if args['--generate-authorities']:
+ log.info('Generating all authorities declared in {} with
force={}'.format(
+ args['<manifest_path>'], args['--force'])
+ )
+ generate_authorities(authorities, force=args['--force'])
+
+ print('\n' + authorities_status_string(authorities))
+
+ if args['--generate-certs']:
+ log.info('Generating all certificates declared in {} with
force={}'.format(
+ args['<manifest_path>'], args['--force'])
+ )
+ generate_certificates(certificates, force=args['--force'])
+
+ print(certificates_status_string(certificates))
+
+if __name__ == '__main__':
+ main()
\ No newline at end of file
diff --git a/certpy/puppet_sign_cert.rb b/certpy/puppet_sign_cert.rb
new file mode 100755
index 0000000..b52236f
--- /dev/null
+++ b/certpy/puppet_sign_cert.rb
@@ -0,0 +1,168 @@
+#!/usr/bin/env ruby
+# Copyright (c) 2016 Giuseppe Lavagetto, Wikimedia Foundation
+# Loosely based on
https://github.com/ripienaar/mcollective-choria/blob/master/lib/mcollective/util/choria.rb
+
+# TODO THIS HAS NOT BEEN TESTED!
+
+require 'json'
+require 'logger'
+require 'net/http'
+require 'openssl'
+require 'optparse'
+require 'yaml'
+
+require 'puppet'
+require 'puppet/ssl/certificate_authority'
+require 'puppet/util/command_line'
+
+OpenSSL::PKey::EC.send(:alias_method, :private?, :private_key?)
+
+class PuppetCertifcateSignError < StandardError
+end
+
+args = {
+ configfile: nil,
+ cert_dir: '/var/lib/puppet/ssl/certs',
+ key_dir: '/var/lib/puppet/ssl/private_keys',
+ organization: 'Wikimedia Foundation, Inc.',
+ country: 'US',
+ state: 'California',
+ locality: 'San Francisco',
+ puppetca: 'puppet',
+ altnames: [],
+ asn1_oid: 'prime256v1'
+}
+
+
+Log = Logger.new(STDOUT)
+
+Log.level = Logger::INFO
+Log.formatter = proc do |severity, datetime, _, msg|
+ date_format = datetime.strftime("%Y-%m-%d %H:%M:%S")
+ format("%s %-5s (puppet-sign-cert): %s\n", date_format, severity, msg)
+end
+
+
+
+module Puppet
+ module SSL
+ # Extend the signing checks
+ module CertificateAuthorityExtensions
+ def check_internal_signing_policies(hostname, csr, _allow_dns_alt_names)
+ super(hostname, csr, true)
+ rescue Puppet::SSL::CertificateAuthority::CertificateSigningError => e
+ if e.message.start_with?("CSR '#{csr.name}' subjectAltName contains a
wildcard")
+ true
+ else
+ raise
+ end
+ end
+ end
+ # Extend the base class
+ class CertificateAuthority
+ prepend Puppet::SSL::CertificateAuthorityExtensions
+ end
+ end
+end
+
+
+
+
+def read_csr_file(csr_path)
+ OpenSSL::X509::Request.new(File.read(csr_path))
+end
+
+def common_name_from_csr(csr)
+ # csr.subject.to_a returns something like:
+ # [["C", "US", 19], ["ST", "CA", 12], ["O", "WMF", 12], ["CN", "testrsa1",
12]]
+ # We want the entry with "CN":
+ csr.subject.to_a.select{|name, _, _| name == 'CN' }.first[1]
+end
+
+def send_csr(csr, hostname: 'puppet', port: 8140)
+ common_name = common_name_from_csr(csr)
+
+ https = Net::HTTP.new(hostname, port)
+ https.use_ssl = true
+ # TODO: fix this
+ https.verify_mode = OpenSSL::SSL::VERIFY_NONE
+
+
+ req = Net::HTTP::Put.new("/production/certificate_request/#{common_name}",
+ 'Content-Type' => 'text/plain')
+ req.body = csr.to_s
+
+ Log.info "Submitting CSR for #{common_name} to #{hostname}:#{port}"
+ resp, _ = https.request(req)
+ fail(PuppetCertifcateSignError,
+ format('CSR for %s to %s:%s failed with code %s: %s',
+ common_name, hostname, port, resp.code, resp.body)
+ ) unless resp.code == '200'
+ Log.info "CSR succeeded"
+end
+
+
+def cert_exists(csr)
+ #TODO
https://docs.puppet.com/puppet/3.8/http_api/http_certificate_status.html
+ # GET /:environment/certificate_status/:certname but gives forbidden???
+ false
+end
+
+def sign_csr(csr)
+ common_name = common_name_from_csr(csr)
+ Log.info "Now signing the certificate for #{common_name}"
+ # Now sign the cert using puppet's own commandline interpreter
+ Puppet::Util::CommandLine.new('cert', ['sign', common_name]).execute
+end
+
+
+def send_and_sign_csr(csr_path, hostname: 'puppet', port: 8140)
+ csr = read_csr_file(csr_path)
+
+ # TODO:
+ # if cert_exists
+
+
+ send_csr(csr, hostname: hostname, port: port)
+ sign_csr(csr)
+end
+
+
+
+
+# Ruby main :)
+if __FILE__ == $0;
+
+ args = {
+ ca_hostname: 'puppet',
+ ca_port: 8140,
+ }
+
+ OptionParser.new do |opts|
+ opts.banner = "Usage: puppet-sign-cert [-H <puppet-ca-hostname>] [-P
<puppet-ca-port>] <csr-path>"
+
+ opts.on('-H', '--ca-hostname HOSTNAME', 'Puppet CA hostname') do |hostname|
+ args[:ca_hostname] = hostname
+ end
+
+ opts.on('-P', '--ca-port', 'Puppet CA port') do |port|
+ args[:ca_port] = port
+ end
+
+ opts.on('-d', '--debug', 'Show debug information') do
+ Log.level = Logger::DEBUG
+ end
+ end.parse!
+
+ csr_path = ARGV.shift || ''
+
+ fail(PuppetCertifcateSignError, 'You must provide a path to a .csr file in
.pem format') unless csr_path != ''
+
+ begin
+ send_and_sign_csr(csr_path, hostname: args[:ca_hostname], port:
args[:ca_port])
+ rescue PuppetCertifcateSignError => e
+ Log.error "#{e.message}"
+ exit 1
+ end
+
+end
diff --git a/certpy/util.py b/certpy/util.py
new file mode 100644
index 0000000..bcf0c21
--- /dev/null
+++ b/certpy/util.py
@@ -0,0 +1,112 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+import logging
+import os
+import os.path
+import subprocess
+import tempfile
+import yaml # PyYAML (python-yaml)
+
+from .ca import *
+
+openssl = os.getenv('OPENSSL_BIN', 'openssl')
+keytool = os.getenv('KEYTOOL_BIN', 'keytool')
+
+__all__ = (
+ 'setup_logging', 'get_class_logger', 'run_command', 'mkdirs',
'is_in_keystore'
+)
+
+
+def setup_logging(level=None):
+ """
+ Conigures basic logging defaults.
+ If level is not given, but the environment variable LOG_LEVEL
+ is set, it will be used as the level. Otherwise INFO is the default level.
+
+ :param level
+ """
+ if not level:
+ level = getattr(
+ logging, os.environ.get('LOG_LEVEL', 'INFO')
+ )
+
+ logging.basicConfig(
+ level=level,
+ format='%(asctime)s %(levelname)-8s %(name)-22s %(message)s'
+ )
+
+
+def get_class_logger(obj):
+ """
+ Returns a new logging instance for an class object instance.
+ If the obj instance has a name attribute, it will be included
+ in the logger name. This is useful for using %(name)s in
+ logging your formatter.
+
+ :param obj an instance of any class
+ """
+ class_name = obj.__class__.__name__
+ if hasattr(obj, 'name'):
+ logger_name = '{}({})'.format(class_name, obj.name)
+ else:
+ logger_name = class_name
+ return logging.getLogger(logger_name)
+
+
+def run_command(command, creates=None):
+ """
+ Executes a command in a subshell and logs the output.
+
+ :param command a list of command args to pass to subprocess.check_output
+ :return True if the command exited with 0, else False
+ """
+ logger = logging.getLogger('shell')
+
+ if isinstance(command, str):
+ command = command.split()
+ try:
+ logger.debug("Running command: " + " ".join(command))
+ output = subprocess.check_output(command, stderr=subprocess.STDOUT)
+ for ln in output.splitlines(): logger.debug(ln)
+
+ # Ensure that any files that this command should have created exist.
+ if creates:
+ if isinstance(creates, str):
+ creates = [creates]
+ for f in creates:
+ if not os.path.exists(f):
+ logger.error(
+ 'command succeeded, but was expected to create file {}
'
+ 'and it does not exist. command: {}'.format(f, '
'.join(command))
+ )
+ return False
+
+ except subprocess.CalledProcessError as e:
+ for ln in e.output.splitlines(): logging.error(ln)
+ logger.error("command returned status %d: %s", e.returncode, "
".join(command))
+ return False
+
+
+ logger.debug("command succeeded: %s", " ".join(command))
+ return True
+
+
+def mkdirs(directory):
+ """
+ Equivalent to mkdir -p
+
+ :param directory path
+ """
+ if not os.path.exists(directory):
+ os.makedirs(directory)
+
+def is_in_keystore(alias, jks_file, password):
+ command = [
+ keytool,
+ '-list',
+ '-alias', alias,
+ '-storepass', password,
+ '-keystore', jks_file
+ ]
+ return run_command(command)
diff --git a/examples/example.certs.yaml b/examples/example.certs.yaml
new file mode 100644
index 0000000..4eee53a
--- /dev/null
+++ b/examples/example.certs.yaml
@@ -0,0 +1,35 @@
+authorities:
+ rootCa:
+ type: SelfSigningCA
+ cert:
+ path: certificates/rootCa
+ subject:
+ C: US
+ ST: CA
+ password: qwerty
+ key:
+ type: RSAKey
+ password: qwerty
+
+certs:
+ hostname1.example.org:
+ path: certificates/hostname1.example.org
+ ca: rootCa
+ subject:
+ C: US
+ ST: CA
+ dns_alt_names: [me.we.you, fine.com]
+ password: qwerty
+ key:
+ type: ECKey
+ password: qwerty
+
+ clientA:
+ path: certificates/clientA
+ ca: rootCa
+ subject:
+ C: US
+ ST: CA
+ password: qwerty
+ key:
+ type: ECKey
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..c13b703
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,26 @@
+# -*- coding: utf-8 -*-
+from setuptools import setup, find_packages
+
+try:
+ long_description = open('README.md').read()
+except IOError:
+ long_description = ''
+
+setup(
+ name='certpy',
+ version='0.1.0',
+ description='OpenSSL Certificate Manager',
+ license='Apache',
+ author='Andrew Otto',
+ packages=find_packages(),
+ install_requires=[
+ 'docopt',
+ 'yamlreader',
+ ],
+ long_description=long_description,
+ entry_points={'console_scripts': ['certpy = certpy.main:main']},
+ classifiers=[
+ 'Programming Language :: Python',
+ 'Programming Language :: Python :: 3.3',
+ ]
+)
--
To view, visit https://gerrit.wikimedia.org/r/359960
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings
Gerrit-MessageType: newchange
Gerrit-Change-Id: Ie1a251caa6eaafc3d53120eff85ddb5e7b77369c
Gerrit-PatchSet: 1
Gerrit-Project: operations/software/certpy
Gerrit-Branch: master
Gerrit-Owner: Ottomata <[email protected]>
_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits