diff --git a/functional_tests/test_init.py b/functional_tests/test_init.py index 49f6c0f02299a8c1c60fda2b055fc07213a1ba3a..ff2a55ce36e446eaec467c3479f3b2e4c138b294 100644 --- a/functional_tests/test_init.py +++ b/functional_tests/test_init.py @@ -41,3 +41,60 @@ def test_init_command_available_with_help(): assert returncode == 0 assert stderr == "" assert stdout.startswith("usage: gimmecert init") + + +def test_initialisation_on_fresh_directory(tmpdir): + # After reading the help, John decides it's time to initialise the + # CA hierarchy so he can use it for issuing server and client + # certificates in his project. + + # John switches to his project directory. + tmpdir.chdir() + + # He runs the initialisation command. + stdout, stderr, exit_code = run_command('gimmecert', 'init') + + # The tool exits without any errors, and shows some informative + # text to John that the directory has been initialised. + assert exit_code == 0 + assert stderr == "" + assert "CA hierarchy initialised" in stdout + + # The tool also points John to generated key and certificate material. + assert ".gimmecert/ca/level1.key.pem" in stdout + assert ".gimmecert/ca/level1.cert.pem" in stdout + assert ".gimmecert/ca/chain-full.cert.pem" in stdout + + # Happy that he didn't have to enter long commands, John inspects + # the CA key first using the OpenSSL CLI. + stdout, stderr, exit_code = run_command('openssl', 'rsa', '-noout', '-text', '-in', '.gimmecert/ca/level1.key.pem') + + # No errors are reported, and John is able ot see some details + # about the generated key. + assert exit_code == 0 + assert stderr == "" + assert "Private-Key: (2048 bit)" in stdout + + # John then has a look at the generated certificate file. + stdout, stderr, exit_code = run_command('openssl', 'x509', '-noout', '-text', '-in', '.gimmecert/ca/level1.cert.pem') + + # With no errors again, he can see some of the details in + # certificate. + assert 'Certificate:' in stdout + + # John runs reads the issuer and subject DN stored in certificate. + issuer_dn, _, _ = run_command('openssl', 'x509', '-noout', '-issuer', '-in', '.gimmecert/ca/level1.cert.pem') + subject_dn, _, _ = run_command('openssl', 'x509', '-noout', '-subject', '-in', '.gimmecert/ca/level1.cert.pem') + issuer_dn = issuer_dn.replace('issuer=', '', 1) + subject_dn = subject_dn.replace('subject=', '', 1) + + # He notices that the issuer and subject DN are identical (since + # it's a root CA certificate), and can also see that the subject + # DN has just the CN with working directory's name in it. + assert issuer_dn == subject_dn + assert subject_dn.rstrip() == 'CN = %s Level 1' % tmpdir.basename + + # John has a quick look at generated certificate and chain, only + # to realise they are identical. + with open(".gimmecert/ca/level1.cert.pem") as cert_file, open(".gimmecert/ca/chain-full.cert.pem") as chain_file: + assert cert_file.read() == chain_file.read() diff --git a/gimmecert/cli.py b/gimmecert/cli.py index aa95063660a74aaae9a6f84fcd37e92859c3f3cb..263ec76644bf19c339ca9f37dbb685d04caf4571 100644 --- a/gimmecert/cli.py +++ b/gimmecert/cli.py @@ -20,8 +20,11 @@ import argparse +import os from .decorators import subcommand_parser, get_subcommand_parser_setup_functions +from .storage import initialise_storage, write_private_key, write_certificate +from .crypto import generate_private_key, issue_certificate, get_dn, get_validity_range DESCRIPTION = """\ @@ -37,7 +40,28 @@ def setup_init_subcommand_parser(parser, subparsers): subparser = subparsers.add_parser('init', description='Initialise CA hierarchy.') def init(args): - pass + project_directory = os.getcwd() + base_directory = os.path.join(os.getcwd(), '.gimmecert') + ca_directory = os.path.join(base_directory, 'ca') + level1_private_key_path = os.path.join(ca_directory, 'level1.key.pem') + level1_certificate_path = os.path.join(ca_directory, 'level1.cert.pem') + full_chain_path = os.path.join(ca_directory, 'chain-full.cert.pem') + level1_dn = get_dn("%s Level 1" % os.path.basename(project_directory)) + not_before, not_after = get_validity_range() + + initialise_storage(project_directory) + level1_private_key = generate_private_key() + level1_certificate = issue_certificate(level1_dn, level1_dn, level1_private_key, level1_private_key.public_key(), not_before, not_after) + full_chain = level1_certificate + + write_private_key(level1_private_key, level1_private_key_path) + write_certificate(level1_certificate, level1_certificate_path) + write_certificate(full_chain, full_chain_path) + + print("CA hierarchy initialised. Generated artefacts:") + print(" CA Level 1 private key: .gimmecert/ca/level1.key.pem") + print(" CA Level 1 certificate: .gimmecert/ca/level1.cert.pem") + print(" Full certificate chain: .gimmecert/ca/chain-full.cert.pem") subparser.set_defaults(func=init) diff --git a/gimmecert/crypto.py b/gimmecert/crypto.py new file mode 100644 index 0000000000000000000000000000000000000000..8d355c9d5fb7fae3730dec7eeb1e944313abbd96 --- /dev/null +++ b/gimmecert/crypto.py @@ -0,0 +1,124 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2018 Branko Majic +# +# This file is part of Gimmecert. +# +# Gimmecert is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# Gimmecert is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Gimmecert. If not, see . +# + +import datetime + +import cryptography.hazmat.primitives.asymmetric.rsa +import cryptography.x509 +from dateutil.relativedelta import relativedelta + + +def generate_private_key(): + """ + Generates a 2048-bit RSA private key. + + :returns: RSA private key. + :rtype: cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey + """ + + rsa_public_exponent = 65537 + key_size = 2048 + + private_key = cryptography.hazmat.primitives.asymmetric.rsa.generate_private_key( + public_exponent=rsa_public_exponent, + key_size=key_size, + backend=cryptography.hazmat.backends.default_backend() + ) + + return private_key + + +def get_dn(name): + """ + Generates a DN (distinguished name) using the passed-in name. The + resulting DN will consist out of a single CN field, whose value + will be set to the passed-in name. For example, if you pass-in + name "My Name", the resulting DN will be "CN=My Name". + + :returns: Distinguished name with provided value. + :rtype: cryptography.x509.Name + """ + + dn = cryptography.x509.Name([cryptography.x509.NameAttribute(cryptography.x509.oid.NameOID.COMMON_NAME, name)]) + + return dn + + +def get_validity_range(): + """ + Returns validity range usable for issuing certificates. The time + range between beginning and end is one year. + + The beginning will be current time minus 15 minutes (useful in + case of drifting clocks), while ending will be one year ahead of + 15 minutes - for total duration of 1 year and 15 minutes. + + Resulting beginning and ending dates have precision of up to a + second (microseconds are discarded). + + :returns: (not_before, not_after) -- Tuple defining the time range. + :rtype: (datetime.datetime, datetime.datetime) + """ + + now = datetime.datetime.utcnow().replace(microsecond=0) + not_before = now - datetime.timedelta(minutes=15) + not_after = now + relativedelta(years=1) + + return not_before, not_after + + +def issue_certificate(issuer_dn, subject_dn, signing_key, public_key, not_before, not_after): + """ + Issues a certificate using the passed-in data. + + :param issuer_dn: Issuer DN to use in issued certificate. + :type issuer_dn: cryptography.x509.Name + + :param subject_dn: Subject DN to use in issued certificate. + :type subject_dn: cryptography.x509.Name + + :param signing_key: Private key belonging to entity associated with passed-in issuer_dn. Used for signing the certificate data. + :type signing_key: cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey + + :param public_key: Public key belonging to entity associated with passed-in subject_dn. Used as part of certificate to denote its owner. + :type cryptography.hazmat.primitives.asymmetric.rsa.RSAPublicKey: + + :param not_before: Beginning of certifiate validity. + :type datetime.datetime.: + + :param not_after: End of certificate validity. + :type datetime.datetime: + """ + + builder = cryptography.x509.CertificateBuilder() + builder = builder.subject_name(cryptography.x509.Name(subject_dn)) + builder = builder.issuer_name(cryptography.x509.Name(issuer_dn)) + builder = builder.not_valid_before(not_before) + builder = builder.not_valid_after(not_after) + builder = builder.serial_number(cryptography.x509.random_serial_number()) + builder = builder.public_key(public_key) + + certificate = builder.sign( + private_key=signing_key, + algorithm=cryptography.hazmat.primitives.hashes.SHA256(), + backend=cryptography.hazmat.backends.default_backend() + ) + + return certificate diff --git a/gimmecert/storage.py b/gimmecert/storage.py new file mode 100644 index 0000000000000000000000000000000000000000..38972481926efae6775583249cd5027d17e6af1b --- /dev/null +++ b/gimmecert/storage.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2018 Branko Majic +# +# This file is part of Gimmecert. +# +# Gimmecert is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# Gimmecert is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Gimmecert. If not, see . +# + + +import os + +import cryptography.hazmat.primitives.serialization + + +def initialise_storage(project_directory): + """ + Initialises certificate storage in the given project directory. + + Storage initialisation consists of creating the necessary + directory structure. Directories created under the passed-in + project directory are: + + - .gimmcert/ + - .gimmcert/ca/ + + :param project_directory: Path to directory under which the storage should be initialised. + :type project_directory: str + """ + + os.mkdir(os.path.join(project_directory, '.gimmecert')) + os.mkdir(os.path.join(project_directory, '.gimmecert', 'ca')) + + +def write_private_key(private_key, path): + """ + Writes the passed-in private key to designated path in + OpenSSL-style PEM format. + + The private key is written without any encryption. + + :param private_key: Private key that should be written. + :type private_key: cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey + + :param path: File path where the key should be written. + :type path: str + """ + + private_key_pem = private_key.private_bytes( + encoding=cryptography.hazmat.primitives.serialization.Encoding.PEM, + format=cryptography.hazmat.primitives.serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=cryptography.hazmat.primitives.serialization.NoEncryption() + ) + + with open(path, 'wb') as key_file: + key_file.write(private_key_pem) + + +def write_certificate(certificate, path): + """ + Writes the passed-in certificate to designated path in + OpenSSL-style PEM format. + + :param certificate: Certificate that should be writtent-out. + :type certificate: cryptography.x509.Certificate + + :param path: File path where the certificate should be written. + :type path: str + """ + + certificate_pem = certificate.public_bytes(encoding=cryptography.hazmat.primitives.serialization.Encoding.PEM) + + with open(path, 'wb') as certificate_file: + certificate_file.write(certificate_pem) diff --git a/setup.py b/setup.py index 01589d0d1a7c5dc3a831042012c20e911ecd48cf..260b1ec9e1873949dde3d905fa3f015af21e4b4a 100755 --- a/setup.py +++ b/setup.py @@ -25,6 +25,8 @@ from setuptools import setup, find_packages README = open(os.path.join(os.path.dirname(__file__), 'README.rst')).read() install_requirements = [ + 'cryptography>=2.1,<2.2', + 'python-dateutil>=2.6,<2.7', ] doc_requirements = [ @@ -36,6 +38,7 @@ test_lint_requirements = [ ] test_requirements = test_lint_requirements + [ + 'freezegun>=0.3,<0.4', 'pytest>=3.4,<3.5', 'pytest-cov>=2.5,<2.6', 'pytest-flake8>=0.9,<0.10', diff --git a/tests/test_cli.py b/tests/test_cli.py index 06a3aebb2cdc1b907a4b09473b497e3014c38e6f..c10dc783060cbf0bcf7790f3b55138f80599be64 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -20,6 +20,7 @@ import argparse +import os import gimmecert.cli import gimmecert.decorators @@ -158,3 +159,25 @@ def test_setup_init_subcommand_sets_function_callback(): subparser = gimmecert.cli.setup_init_subcommand_parser(parser, subparsers) assert callable(subparser.get_default('func')) + + +@mock.patch('sys.argv', ['gimmecert', 'init']) +def test_init_subcommand_generates_ca_private_key(tmpdir): + tmpdir.chdir() + + gimmecert.cli.main() + + print(tmpdir.listdir()) + + assert os.path.exists(tmpdir.join('.gimmecert', 'ca', 'level1.key.pem').strpath) + + +@mock.patch('sys.argv', ['gimmecert', 'init']) +def test_init_subcommand_generates_ca_certificate(tmpdir): + tmpdir.chdir() + + gimmecert.cli.main() + + print(tmpdir.listdir()) + + assert os.path.exists(tmpdir.join('.gimmecert', 'ca', 'level1.cert.pem').strpath) diff --git a/tests/test_crypto.py b/tests/test_crypto.py new file mode 100644 index 0000000000000000000000000000000000000000..85139c40c0d3df70820fb870e367de8cbf9551ec --- /dev/null +++ b/tests/test_crypto.py @@ -0,0 +1,102 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2018 Branko Majic +# +# This file is part of Gimmecert. +# +# Gimmecert is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# Gimmecert is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Gimmecert. If not, see . +# + + +import datetime + +import cryptography.hazmat.primitives.asymmetric.rsa +from dateutil.relativedelta import relativedelta + +import gimmecert.crypto + +from freezegun import freeze_time + + +def test_generate_private_key_returns_private_key(): + private_key = gimmecert.crypto.generate_private_key() + + assert isinstance(private_key, cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey) + + +def test_get_dn(): + dn = gimmecert.crypto.get_dn('My test') + assert isinstance(dn, cryptography.x509.Name) + assert len(dn) == 1 + assert isinstance(list(dn)[0], cryptography.x509.NameAttribute) + assert list(dn)[0].oid == cryptography.x509.oid.NameOID.COMMON_NAME + assert list(dn)[0].value == 'My test' + + +def test_get_validity_range_returns_datetime_tuple(): + not_before, not_after = gimmecert.crypto.get_validity_range() + + assert isinstance(not_before, datetime.datetime) + assert isinstance(not_after, datetime.datetime) + + +@freeze_time('2018-01-01 00:15:00') +def test_get_validity_range_not_before_is_within_15_minutes_of_now(): + not_before, _ = gimmecert.crypto.get_validity_range() + + assert not_before == datetime.datetime(2018, 1, 1, 0, 0) + + +@freeze_time('2018-01-01 00:15:00') +def test_get_validity_range_is_one_year_and_15_minutes(): + not_before, not_after = gimmecert.crypto.get_validity_range() + difference = relativedelta(not_after, not_before) + + assert difference == relativedelta(years=1, minutes=15) + + +@freeze_time('2018-01-01 00:15:00.100') +def test_get_validity_range_drops_microseconds(): + not_before, not_after = gimmecert.crypto.get_validity_range() + + assert not_before.microsecond == 0 + assert not_after.microsecond == 0 + + +def test_issue_certificate_returns_certificate(): + + issuer_dn = gimmecert.crypto.get_dn('My test 1') + subject_dn = gimmecert.crypto.get_dn('My test 2') + issuer_private_key = gimmecert.crypto.generate_private_key() + subject_private_key = gimmecert.crypto.generate_private_key() + not_before, not_after = gimmecert.crypto.get_validity_range() + + certificate = gimmecert.crypto.issue_certificate(issuer_dn, subject_dn, issuer_private_key, subject_private_key.public_key(), not_before, not_after) + + assert isinstance(certificate, cryptography.x509.Certificate) + + +def test_issue_certificate_has_correct_content(): + issuer_dn = gimmecert.crypto.get_dn('My test 1') + subject_dn = gimmecert.crypto.get_dn('My test 2') + issuer_private_key = gimmecert.crypto.generate_private_key() + subject_private_key = gimmecert.crypto.generate_private_key() + not_before, not_after = gimmecert.crypto.get_validity_range() + + certificate = gimmecert.crypto.issue_certificate(issuer_dn, subject_dn, issuer_private_key, subject_private_key.public_key(), not_before, not_after) + + assert certificate.issuer == issuer_dn + assert certificate.subject == subject_dn + assert certificate.not_valid_before == not_before + assert certificate.not_valid_after == not_after diff --git a/tests/test_storage.py b/tests/test_storage.py new file mode 100644 index 0000000000000000000000000000000000000000..5dbec4574333a8b157d168fc978af7d7b972d80e --- /dev/null +++ b/tests/test_storage.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2018 Branko Majic +# +# This file is part of Gimmecert. +# +# Gimmecert is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# Gimmecert is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Gimmecert. If not, see . +# + +import os + +import gimmecert.crypto +import gimmecert.storage + + +def test_initialise_storage(tmpdir): + tmpdir.chdir() + + gimmecert.storage.initialise_storage(tmpdir.strpath) + + assert os.path.exists(tmpdir.join('.gimmecert').strpath) + assert os.path.exists(tmpdir.join('.gimmecert', 'ca').strpath) + + +def test_write_private_key(tmpdir): + tmpdir.chdir() + + private_key = gimmecert.crypto.generate_private_key() + key_path = tmpdir.join('test.key.pem').strpath + + gimmecert.storage.write_private_key(private_key, key_path) + + assert os.path.exists(key_path) + + with open(key_path, 'r') as key_file: + content = key_file.read() + assert 'BEGIN RSA PRIVATE KEY' in content + assert 'END RSA PRIVATE KEY' in content + + +def test_write_certificate(tmpdir): + tmpdir.chdir() + + issuer_dn = gimmecert.crypto.get_dn('My test 1') + subject_dn = gimmecert.crypto.get_dn('My test 2') + issuer_private_key = gimmecert.crypto.generate_private_key() + subject_private_key = gimmecert.crypto.generate_private_key() + not_before, not_after = gimmecert.crypto.get_validity_range() + certificate = gimmecert.crypto.issue_certificate(issuer_dn, subject_dn, issuer_private_key, subject_private_key.public_key(), not_before, not_after) + + certificate_path = tmpdir.join('test.key.pem').strpath + + gimmecert.storage.write_certificate(certificate, certificate_path) + + assert os.path.exists(certificate_path) + + with open(certificate_path, 'r') as certificate_file: + content = certificate_file.read() + assert 'BEGIN CERTIFICATE' in content + assert 'END CERTIFICATE' in content