diff --git a/os_cloud_config/cmd/generate_keystone_pki.py b/os_cloud_config/cmd/generate_keystone_pki.py new file mode 100644 index 0000000..b0789be --- /dev/null +++ b/os_cloud_config/cmd/generate_keystone_pki.py @@ -0,0 +1,48 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import argparse +import textwrap + +from os_cloud_config import keystone_pki + + +def parse_args(): + description = textwrap.dedent(""" + Generate 4 files inside for use with Keystone PKI + token signing: + + ca_key.pem - certificate authority key + ca_cert.pem - self-signed certificate authority certificate + signing_key.pem - key for signing tokens + signing_cert.pem - certificate for verifying token validity, the + certificate itself is verifiable by ca_cert.pem + + ca_key.pem doesn't have to (shouldn't) be uploaded to Keystone nodes. + """) + + parser = argparse.ArgumentParser( + description=description, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + 'directory', + metavar='', + help='directory where keys/certs will be generated', + ) + return parser.parse_args() + + +def main(): + args = parse_args() + keystone_pki.create_and_write_ca_and_signing_pairs(args.directory) diff --git a/os_cloud_config/keystone_pki.py b/os_cloud_config/keystone_pki.py new file mode 100644 index 0000000..9ead3fb --- /dev/null +++ b/os_cloud_config/keystone_pki.py @@ -0,0 +1,143 @@ +# -*- coding: utf-8 -*- + +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import logging +import os +from os import path +import stat + +from OpenSSL import crypto + +LOG = logging.getLogger(__name__) + +CA_KEY_SIZE = 2048 +CA_CERT_DAYS = 10 * 365 +SIGNING_KEY_SIZE = 2048 +SIGNING_CERT_DAYS = 10 * 365 +X509_VERSION = 2 + + +def create_ca_pair(cert_serial=1): + """Create CA private key and self-signed certificate. + + CA generation is mostly meant for proof-of-concept + deployments. For real deployments it is suggested to use an + external CA (separate from deployment tools). + + :param cert_serial: serial number of the generated certificate + :type cert_serial: integer + :return: (ca_key_pem, ca_cert_pem) tuple of base64 encoded CA + private key and CA certificate (PEM format) + :rtype: (string, string) + """ + ca_key = crypto.PKey() + ca_key.generate_key(crypto.TYPE_RSA, CA_KEY_SIZE) + LOG.debug('Generated CA key.') + + ca_cert = crypto.X509() + ca_cert.set_version(X509_VERSION) + ca_cert.set_serial_number(cert_serial) + subject = ca_cert.get_subject() + subject.C = 'XX' + subject.ST = 'Unset' + subject.L = 'Unset' + subject.O = 'Unset' + subject.CN = 'Keystone CA' + ca_cert.gmtime_adj_notBefore(0) + ca_cert.gmtime_adj_notAfter(60 * 60 * 24 * CA_CERT_DAYS) + ca_cert.set_issuer(subject) + ca_cert.set_pubkey(ca_key) + ca_cert.add_extensions([ + crypto.X509Extension("basicConstraints", True, "CA:TRUE, pathlen:0"), + ]) + ca_cert.sign(ca_key, 'sha1') + LOG.debug('Generated CA certificate.') + + return (crypto.dump_privatekey(crypto.FILETYPE_PEM, ca_key), + crypto.dump_certificate(crypto.FILETYPE_PEM, ca_cert)) + + +def create_signing_pair(ca_key_pem, ca_cert_pem, cert_serial=2): + """Create signing private key and certificate. + + Os-cloud-config key generation and certificate signing is mostly + meant for proof-of-concept deployments. For real deployments it is + suggested to use certificates signed by an external CA. + + :param ca_key_pem: CA private key to sign the signing certificate, + base64 encoded (PEM format) + :type ca_key_pem: string + :param ca_cert_pem: CA certificate, base64 encoded (PEM format) + :type ca_cert_pem: string + :param cert_serial: serial number of the generated certificate + :type cert_serial: integer + :return: (signing_key_pem, signing_cert_pem) tuple of base64 + encoded signing private key and signing certificate + (PEM format) + :rtype: (string, string) + """ + ca_key = crypto.load_privatekey(crypto.FILETYPE_PEM, ca_key_pem) + ca_cert = crypto.load_certificate(crypto.FILETYPE_PEM, ca_cert_pem) + + signing_key = crypto.PKey() + signing_key.generate_key(crypto.TYPE_RSA, CA_KEY_SIZE) + LOG.debug('Generated signing key.') + + signing_cert = crypto.X509() + signing_cert.set_version(X509_VERSION) + signing_cert.set_serial_number(cert_serial) + subject = signing_cert.get_subject() + subject.C = 'XX' + subject.ST = 'Unset' + subject.L = 'Unset' + subject.O = 'Unset' + subject.CN = 'Keystone Signing' + signing_cert.gmtime_adj_notBefore(0) + signing_cert.gmtime_adj_notAfter(60 * 60 * 24 * SIGNING_CERT_DAYS) + signing_cert.set_issuer(ca_cert.get_subject()) + signing_cert.set_pubkey(signing_key) + signing_cert.sign(ca_key, 'sha1') + LOG.debug('Generated signing certificate.') + + return (crypto.dump_privatekey(crypto.FILETYPE_PEM, signing_key), + crypto.dump_certificate(crypto.FILETYPE_PEM, signing_cert)) + + +def create_and_write_ca_and_signing_pairs(directory): + """Create and write out CA and signing keys and certificates. + + Generate ca_key.pem, ca_cert.pem, signing_key.pem, + signing_cert.pem and write them out to a directory. + + :param directory: directory where keys and certs will be written + :type directory: string + """ + if not path.isdir(directory): + os.mkdir(directory) + + ca_key_pem, ca_cert_pem = create_ca_pair() + signing_key_pem, signing_cert_pem = create_signing_pair(ca_key_pem, + ca_cert_pem) + + _write_pki_file(path.join(directory, 'ca_key.pem'), ca_key_pem) + _write_pki_file(path.join(directory, 'ca_cert.pem'), ca_cert_pem) + _write_pki_file(path.join(directory, 'signing_key.pem'), signing_key_pem) + _write_pki_file(path.join(directory, 'signing_cert.pem'), signing_cert_pem) + + +def _write_pki_file(file_path, contents): + with open(file_path, 'w') as f: + f.write(contents) + os.chmod(file_path, stat.S_IRUSR | stat.S_IWUSR) + LOG.debug("Wrote '%s'.", path.abspath(file_path)) diff --git a/os_cloud_config/tests/test_keystone_pki.py b/os_cloud_config/tests/test_keystone_pki.py new file mode 100644 index 0000000..d533e50 --- /dev/null +++ b/os_cloud_config/tests/test_keystone_pki.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- + +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import stat + +import mock +from OpenSSL import crypto + +from os_cloud_config import keystone_pki +from os_cloud_config.tests import base + + +class KeystonePKITest(base.TestCase): + + def test_create_ca_and_signing_pairs(self): + # use one common test to avoid generating CA pair twice + # do not mock out pyOpenSSL, test generated keys/certs + + # create CA pair + ca_key_pem, ca_cert_pem = keystone_pki.create_ca_pair() + ca_key = crypto.load_privatekey(crypto.FILETYPE_PEM, ca_key_pem) + ca_cert = crypto.load_certificate(crypto.FILETYPE_PEM, ca_cert_pem) + + # check CA key properties + self.assertTrue(ca_key.check()) + self.assertEqual(2048, ca_key.bits()) + + # check CA cert properties + self.assertFalse(ca_cert.has_expired()) + self.assertEqual('Keystone CA', ca_cert.get_issuer().CN) + self.assertEqual('Keystone CA', ca_cert.get_subject().CN) + + # create signing pair + signing_key_pem, signing_cert_pem = keystone_pki.create_signing_pair( + ca_key_pem, ca_cert_pem) + signing_key = crypto.load_privatekey(crypto.FILETYPE_PEM, + signing_key_pem) + signing_cert = crypto.load_certificate(crypto.FILETYPE_PEM, + signing_cert_pem) + + # check signing key properties + self.assertTrue(signing_key.check()) + self.assertEqual(2048, signing_key.bits()) + + # check signing cert properties + self.assertFalse(signing_cert.has_expired()) + self.assertEqual('Keystone CA', signing_cert.get_issuer().CN) + self.assertEqual('Keystone Signing', signing_cert.get_subject().CN) + # pyOpenSSL currenty cannot verify a cert against a CA cert + + @mock.patch('os_cloud_config.keystone_pki.os.chmod', create=True) + @mock.patch('os_cloud_config.keystone_pki.os.mkdir', create=True) + @mock.patch('os_cloud_config.keystone_pki.path.isdir', create=True) + @mock.patch('os_cloud_config.keystone_pki.create_ca_pair') + @mock.patch('os_cloud_config.keystone_pki.create_signing_pair') + @mock.patch('os_cloud_config.keystone_pki.open', create=True) + def test_create_and_write_ca_and_signing_pairs( + self, open_, create_signing, create_ca, isdir, mkdir, chmod): + create_ca.return_value = ('mock_ca_key', 'mock_ca_cert') + create_signing.return_value = ('mock_signing_key', 'mock_signing_cert') + isdir.return_value = False + keystone_pki.create_and_write_ca_and_signing_pairs('/fake_dir') + + mkdir.assert_called_with('/fake_dir') + chmod.assert_has_calls([ + mock.call('/fake_dir/ca_key.pem', + stat.S_IRUSR | stat.S_IWUSR), + mock.call('/fake_dir/ca_cert.pem', + stat.S_IRUSR | stat.S_IWUSR), + mock.call('/fake_dir/signing_key.pem', + stat.S_IRUSR | stat.S_IWUSR), + mock.call('/fake_dir/signing_cert.pem', + stat.S_IRUSR | stat.S_IWUSR), + ]) + # need any_order param, there are open().__enter__() + # etc. called in between + open_.assert_has_calls([ + mock.call('/fake_dir/ca_key.pem', 'w'), + mock.call('/fake_dir/ca_cert.pem', 'w'), + mock.call('/fake_dir/signing_key.pem', 'w'), + mock.call('/fake_dir/signing_cert.pem', 'w'), + ], any_order=True) + cert_files = open_.return_value.__enter__.return_value + cert_files.write.assert_has_calls([ + mock.call('mock_ca_key'), + mock.call('mock_ca_cert'), + mock.call('mock_signing_key'), + mock.call('mock_signing_cert'), + ]) diff --git a/requirements.txt b/requirements.txt index eb745e4..763f1bf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,4 +5,5 @@ Babel>=1.3 python-ironicclient python-keystoneclient>=0.6.0 oslo.config>=1.2.0 +pyOpenSSL>=0.11 simplejson>=2.0.9 diff --git a/setup.cfg b/setup.cfg index 935f6a5..1fff2ba 100644 --- a/setup.cfg +++ b/setup.cfg @@ -25,6 +25,7 @@ packages = [entry_points] console_scripts = + generate-keystone-pki = os_cloud_config.cmd.generate_keystone_pki:main init-keystone = os_cloud_config.cmd.init_keystone:main register-nodes = os_cloud_config.cmd.register_nodes:main