Changeset - 9f09715ce550
[Not reviewed]
0 4 4
Branko Majic (branko) - 7 years ago 2018-02-28 15:13:44
branko@majic.rs
GC-3: Implemented CA hierarchy initialisation:

- Added functional test for initialising hierarchy on a fresh
directory.
- Implemented the init command.
- Added two new modules for working with storage and crypto.
- Added cryptography (for certificate issuance and crypto) and
python-dateutil (for better handling of certificate validities) as
installation dependencies.
- Added freezegun as test dependency (helps with testing validity
dates).
- Implemented necessary unit tests.
8 files changed with 490 insertions and 1 deletions:
0 comments (0 inline, 0 general)
functional_tests/test_init.py
Show inline comments
 
@@ -38,6 +38,63 @@ def test_init_command_available_with_help():
 

	
 
    # John notices that this command has some useful usage
 
    # instructions, which allows him to study the available arguments.
 
    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()
gimmecert/cli.py
Show inline comments
 
@@ -17,14 +17,17 @@
 
# You should have received a copy of the GNU General Public License along with
 
# Gimmecert.  If not, see <http://www.gnu.org/licenses/>.
 
#
 

	
 

	
 
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 = """\
 
Issues server and client X.509 certificates using a local CA
 
hierarchy.
 

	
 
@@ -34,13 +37,34 @@ Examples:
 

	
 
@subcommand_parser
 
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)
 

	
 
    return subparser
 

	
 

	
gimmecert/crypto.py
Show inline comments
 
new file 100644
 
# -*- 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 <http://www.gnu.org/licenses/>.
 
#
 

	
 
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
gimmecert/storage.py
Show inline comments
 
new file 100644
 
# -*- 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 <http://www.gnu.org/licenses/>.
 
#
 

	
 

	
 
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)
setup.py
Show inline comments
 
@@ -22,23 +22,26 @@
 
import os
 
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 = [
 
    'sphinx>=1.7,<1.8',
 
]
 

	
 
test_lint_requirements = [
 
    'flake8>=3.5,<3.6',
 
]
 

	
 
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',
 
    'tox>=2.9,<2.10',
 
]
 

	
tests/test_cli.py
Show inline comments
 
@@ -17,12 +17,13 @@
 
# You should have received a copy of the GNU General Public License along with
 
# Gimmecert.  If not, see <http://www.gnu.org/licenses/>.
 
#
 

	
 

	
 
import argparse
 
import os
 

	
 
import gimmecert.cli
 
import gimmecert.decorators
 

	
 
from unittest import mock
 

	
 
@@ -155,6 +156,28 @@ def test_setup_init_subcommand_sets_function_callback():
 
    parser = argparse.ArgumentParser()
 
    subparsers = parser.add_subparsers()
 

	
 
    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)
tests/test_crypto.py
Show inline comments
 
new file 100644
 
# -*- 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 <http://www.gnu.org/licenses/>.
 
#
 

	
 

	
 
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
tests/test_storage.py
Show inline comments
 
new file 100644
 
# -*- 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 <http://www.gnu.org/licenses/>.
 
#
 

	
 
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
0 comments (0 inline, 0 general)