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