diff --git a/functional_tests/test_client.py b/functional_tests/test_client.py index 2f6e6f9d624028ccd9d17f16832d558449a56eda..f674a550a24a3a0146ee5ebd258dc56e76bf027a 100644 --- a/functional_tests/test_client.py +++ b/functional_tests/test_client.py @@ -61,3 +61,75 @@ def test_client_command_requires_initialised_hierarchy(tmpdir): assert stdout == "" assert stderr == "CA hierarchy must be initialised prior to issuing client certificates. Run the gimmecert init command first.\n" assert exit_code != 0 + + +def test_client_command_issues_client_certificate(tmpdir): + # John is about to issue a client certificate. He switches to his + # project directory, and initialises the CA hierarchy there. + tmpdir.chdir() + run_command("gimmecert", "init") + + # He then runs command for issuing a client certificate. + stdout, stderr, exit_code = run_command('gimmecert', 'client', 'myclient') + + # John notices that the command has run without an error, and that + # it has printed out path to the private key and certificate. + assert stderr == "" + assert exit_code == 0 + assert "Client certificate issued." in stdout + assert ".gimmecert/client/myclient.key.pem" in stdout + assert ".gimmecert/client/myclient.cert.pem" in stdout + + # John has a look at the generated private key using the OpenSSL + # CLI. + stdout, stderr, exit_code = run_command('openssl', 'rsa', '-noout', '-text', '-in', '.gimmecert/client/myclient.key.pem') + + # No errors are reported, and John is able to 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/client/myclient.cert.pem') + + # Once again, there are no errors, and he can see some details + # about the certificate. + assert exit_code == 0 + assert stderr == "" + assert 'Certificate:' in stdout + + # John has a quick look at issuer and subject DN stored in + # certificate. + issuer_dn, _, _ = run_command('openssl', 'x509', '-noout', '-issuer', '-in', '.gimmecert/client/myclient.cert.pem') + subject_dn, _, _ = run_command('openssl', 'x509', '-noout', '-subject', '-in', '.gimmecert/client/myclient.cert.pem') + issuer_dn = issuer_dn.replace('issuer=', '', 1).rstrip().replace(' /CN=', 'CN = ', 1) # OpenSSL 1.0 vs 1.1 formatting + subject_dn = subject_dn.replace('subject=', '', 1).rstrip().replace(' /CN=', 'CN = ', 1) # OpenSSL 1.0 vs 1.1 formatting + + # He notices the issuer DN is as expected based on the directory + # name, and that client certificate subject DN simply has CN field + # with the name he provided earlier. + assert issuer_dn == "CN = %s Level 1 CA" % tmpdir.basename + assert subject_dn == "CN = myclient" + + # John takes a look at certificate purpose, since he wants to + # ensure it is a proper TLS client certificate. + stdout, stderr, exit_code = run_command('openssl', 'x509', '-noout', '-purpose', '-in', '.gimmecert/client/myclient.cert.pem') + + # He verifies that the provided certificate has correct purpose. + assert "SSL client : Yes" in stdout + assert "SSL client CA : No" in stdout + assert "SSL server CA : No" in stdout + assert "SSL server : No" in stdout + + # Finally, he decides to check if the certificate can be verified + # using the CA certificate chain. + _, _, error_code = run_command( + "openssl", "verify", + "-CAfile", + ".gimmecert/ca/chain-full.cert.pem", + ".gimmecert/client/myclient.cert.pem" + ) + + # He is happy to see that verification succeeds. + assert error_code == 0 diff --git a/gimmecert/cli.py b/gimmecert/cli.py index 36df5f9e2d0933e0f7c87be4000462ef82e2253f..ae3bd73d1f0d966a37e616e76ea3e5ec46b2d8dc 100644 --- a/gimmecert/cli.py +++ b/gimmecert/cli.py @@ -104,7 +104,7 @@ def setup_client_subcommand_parser(parser, subparsers): def client_wrapper(args): project_directory = os.getcwd() - return client(sys.stdout, sys.stderr, project_directory) + return client(sys.stdout, sys.stderr, project_directory, args.entity_name) subparser.set_defaults(func=client_wrapper) diff --git a/gimmecert/commands.py b/gimmecert/commands.py index 4899615a50fc6697d55ae0afc22494319c47f3ed..e735be9b3a18107acb00402c5307b3d46c1befea 100644 --- a/gimmecert/commands.py +++ b/gimmecert/commands.py @@ -188,7 +188,44 @@ def usage(stdout, stderr, parser): return ExitCode.SUCCESS -def client(stdout, stderr, project_directory): +def client(stdout, stderr, project_directory, entity_name): + """ + Generates a client private key and issues a client certificate + using the CA hierarchy initialised within the specified directory. + + :param stdout: Output stream where the informative messages should be written-out. + :type stdout: io.IOBase + + :param stderr: Output stream where the error messages should be written-out. + :type stderr: io.IOBase + + :param project_directory: Path to project directory under which the CA artifacats etc will be looked-up. + :type project_directory: str + + :param entity_name: Name of the client entity. Name will be used in subject DN. + :type entity_name: str + + :returns: Status code, one from gimmecert.commands.ExitCode. + :rtype: int + """ + + private_key_path = os.path.join(project_directory, '.gimmecert', 'client', '%s.key.pem' % entity_name) + certificate_path = os.path.join(project_directory, '.gimmecert', 'client', '%s.cert.pem' % entity_name) + if not gimmecert.storage.is_initialised(project_directory): print("CA hierarchy must be initialised prior to issuing client certificates. Run the gimmecert init command first.", file=stderr) return ExitCode.ERROR_NOT_INITIALISED + + ca_hierarchy = gimmecert.storage.read_ca_hierarchy(os.path.join(project_directory, '.gimmecert', 'ca')) + issuer_private_key, issuer_certificate = ca_hierarchy[-1] + private_key = gimmecert.crypto.generate_private_key() + certificate = gimmecert.crypto.issue_client_certificate(entity_name, private_key.public_key(), issuer_private_key, issuer_certificate) + + gimmecert.storage.write_private_key(private_key, private_key_path) + gimmecert.storage.write_certificate(certificate, certificate_path) + + print("""Client certificate issued.\n + Client private key: .gimmecert/client/%s.key.pem\n + Client certificate: .gimmecert/client/%s.cert.pem""" % (entity_name, entity_name), file=stdout) + + return ExitCode.SUCCESS diff --git a/gimmecert/crypto.py b/gimmecert/crypto.py index 057f891fc273ce3c76f1f90aff87762c53939050..7ace5f48de955548f8f494421e3c16b67edb468b 100644 --- a/gimmecert/crypto.py +++ b/gimmecert/crypto.py @@ -244,3 +244,61 @@ def issue_server_certificate(name, public_key, issuer_private_key, issuer_certif certificate = issue_certificate(issuer_certificate.issuer, dn, issuer_private_key, public_key, not_before, not_after, extensions) return certificate + + +def issue_client_certificate(name, public_key, issuer_private_key, issuer_certificate): + """ + Issues a client certificate. The resulting certificate will use + the passed-in name for subject DN. + + The client certificate key usages and extended key usages are set + to comply with requirements for using such certificates as TLS + server certificates. + + Client certificate validity will not exceed the CA validity. + + :param name: Name of the client end entity. Name will be part of subject DN CN field. + :type name: str + + :param public_key: Public key of the server end entity. + :type public_key: cryptography.hazmat.primitives.asymmetric.rsa.RSAPublicKey + + :param issuer_private_key: Private key of the issuer to use for signing the client certificate structure. + :type issuer_private_key: cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey + + :param issuer_certificate: Certificate of certificate issuer. Naming and validity constraints will be applied based on its content. + :type issuer_certificate: cryptography.x509.Certificate + + :returns: Client certificate issued by designated issuer. + :rtype: cryptography.x509.Certificate + """ + + dn = get_dn(name) + not_before, not_after = get_validity_range() + extensions = [ + (cryptography.x509.BasicConstraints(ca=False, path_length=None), True), + ( + cryptography.x509.KeyUsage( + digital_signature=True, + key_encipherment=True, + content_commitment=False, + data_encipherment=False, + key_agreement=False, + key_cert_sign=False, + crl_sign=False, + encipher_only=False, + decipher_only=False + ), True + ), + (cryptography.x509.ExtendedKeyUsage([cryptography.x509.oid.ExtendedKeyUsageOID.CLIENT_AUTH]), True), + ] + + if not_before < issuer_certificate.not_valid_before: + not_before = issuer_certificate.not_valid_before + + if not_after > issuer_certificate.not_valid_after: + not_after = issuer_certificate.not_valid_after + + certificate = issue_certificate(issuer_certificate.issuer, dn, issuer_private_key, public_key, not_before, not_after, extensions) + + return certificate diff --git a/gimmecert/storage.py b/gimmecert/storage.py index 0d777732f8a19ad68e60065efc272932b42a2b33..89e86212dfcb1b75668f577caf75c88228cb7168 100644 --- a/gimmecert/storage.py +++ b/gimmecert/storage.py @@ -44,6 +44,7 @@ def initialise_storage(project_directory): os.mkdir(os.path.join(project_directory, '.gimmecert')) os.mkdir(os.path.join(project_directory, '.gimmecert', 'ca')) os.mkdir(os.path.join(project_directory, '.gimmecert', 'server')) + os.mkdir(os.path.join(project_directory, '.gimmecert', 'client')) def write_private_key(private_key, path): diff --git a/tests/test_cli.py b/tests/test_cli.py index 2d96573168ae7b5c99276fb66d200c435056ab38..4f06afe1d7e533b0afad42e4bc14165394b7edac 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -532,4 +532,4 @@ def test_client_command_invoked_with_correct_parameters(mock_client, tmpdir): gimmecert.cli.main() - mock_client.assert_called_once_with(sys.stdout, sys.stderr, tmpdir.strpath) + mock_client.assert_called_once_with(sys.stdout, sys.stderr, tmpdir.strpath, 'myclient') diff --git a/tests/test_commands.py b/tests/test_commands.py index 93549be9d5111740b088668765f827c219bf6b0e..fae31ef4bb46daacebe3d586b043428653ec7b40 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -322,7 +322,7 @@ def test_client_reports_error_if_directory_is_not_initialised(tmpdir): stdout_stream = io.StringIO() stderr_stream = io.StringIO() - status_code = gimmecert.commands.client(stdout_stream, stderr_stream, tmpdir.strpath) + status_code = gimmecert.commands.client(stdout_stream, stderr_stream, tmpdir.strpath, 'myclient') stdout = stdout_stream.getvalue() stderr = stderr_stream.getvalue() @@ -333,6 +333,58 @@ def test_client_reports_error_if_directory_is_not_initialised(tmpdir): def test_client_returns_status_code(tmpdir): - status_code = gimmecert.commands.client(io.StringIO(), io.StringIO(), tmpdir.strpath) + status_code = gimmecert.commands.client(io.StringIO(), io.StringIO(), tmpdir.strpath, 'myclient') assert isinstance(status_code, int) + + +def test_client_reports_success_and_paths_to_generated_artifacts(tmpdir): + depth = 1 + + stdout_stream = io.StringIO() + stderr_stream = io.StringIO() + + gimmecert.commands.init(io.StringIO(), io.StringIO(), tmpdir.strpath, tmpdir.basename, depth) + + status_code = gimmecert.commands.client(stdout_stream, stderr_stream, tmpdir.strpath, 'myclient') + + stdout = stdout_stream.getvalue() + stderr = stderr_stream.getvalue() + + assert status_code == gimmecert.commands.ExitCode.SUCCESS + assert "certificate issued" in stdout + assert ".gimmecert/client/myclient.cert.pem" in stdout + assert ".gimmecert/client/myclient.key.pem" in stdout + assert stderr == "" + + +def test_client_outputs_private_key_to_file(tmpdir): + depth = 1 + private_key_file = tmpdir.join('.gimmecert', 'client', 'myclient.key.pem') + + gimmecert.commands.init(io.StringIO(), io.StringIO(), tmpdir.strpath, tmpdir.basename, depth) + + gimmecert.commands.client(io.StringIO(), io.StringIO(), tmpdir.strpath, 'myclient') + + assert private_key_file.check(file=1) + + private_key_file_content = private_key_file.read() + + assert private_key_file_content.startswith('-----BEGIN RSA PRIVATE KEY') + assert private_key_file_content.endswith('END RSA PRIVATE KEY-----\n') + + +def test_client_outputs_certificate_to_file(tmpdir): + depth = 1 + certificate_file = tmpdir.join('.gimmecert', 'client', 'myclient.cert.pem') + + gimmecert.commands.init(io.StringIO(), io.StringIO(), tmpdir.strpath, tmpdir.basename, depth) + + gimmecert.commands.client(io.StringIO(), io.StringIO(), tmpdir.strpath, 'myclient') + + assert certificate_file.check(file=1) + + certificate_file_content = certificate_file.read() + + assert certificate_file_content.startswith('-----BEGIN CERTIFICATE-----') + assert certificate_file_content.endswith('-----END CERTIFICATE-----\n') diff --git a/tests/test_crypto.py b/tests/test_crypto.py index e54b478995fdec94613c6fd3ac2f75d5efe0657e..e4474c05f9e9de5e4993be2523fd905517576084 100644 --- a/tests/test_crypto.py +++ b/tests/test_crypto.py @@ -398,3 +398,114 @@ def test_issue_server_certificate_incorporates_additional_dns_subject_alternativ assert certificate.extensions.get_extension_for_class(cryptography.x509.SubjectAlternativeName).critical is False assert certificate.extensions.get_extension_for_class(cryptography.x509.SubjectAlternativeName).value == expected_subject_alternative_name + + +def test_issue_client_certificate_returns_certificate(): + ca_hierarchy = gimmecert.crypto.generate_ca_hierarchy('My Project', 1) + issuer_private_key, issuer_certificate = ca_hierarchy[0] + + private_key = gimmecert.crypto.generate_private_key() + + certificate = gimmecert.crypto.issue_client_certificate('myclient', private_key.public_key(), issuer_private_key, issuer_certificate) + + assert isinstance(certificate, cryptography.x509.Certificate) + + +def test_issue_client_certificate_has_correct_issuer_and_subject(): + ca_hierarchy = gimmecert.crypto.generate_ca_hierarchy('My Project', 1) + issuer_private_key, issuer_certificate = ca_hierarchy[0] + + private_key = gimmecert.crypto.generate_private_key() + + certificate = gimmecert.crypto.issue_client_certificate('myclient', private_key.public_key(), issuer_private_key, issuer_certificate) + + assert certificate.issuer == issuer_certificate.subject + assert certificate.subject == gimmecert.crypto.get_dn('myclient') + + +def test_issue_client_certificate_sets_correct_extensions(): + ca_hierarchy = gimmecert.crypto.generate_ca_hierarchy('My Project', 1) + issuer_private_key, issuer_certificate = ca_hierarchy[0] + + private_key = gimmecert.crypto.generate_private_key() + + expected_basic_constraints = cryptography.x509.BasicConstraints(ca=False, path_length=None) + expected_key_usage = cryptography.x509.KeyUsage( + digital_signature=True, + key_encipherment=True, + content_commitment=False, + data_encipherment=False, + key_agreement=False, + key_cert_sign=False, + crl_sign=False, + encipher_only=False, + decipher_only=False + ) + expected_extended_key_usage = cryptography.x509.ExtendedKeyUsage( + [ + cryptography.x509.oid.ExtendedKeyUsageOID.CLIENT_AUTH + ] + ) + + certificate = gimmecert.crypto.issue_client_certificate('myclient', private_key.public_key(), issuer_private_key, issuer_certificate) + + assert len(certificate.extensions) == 3 + assert certificate.extensions.get_extension_for_class(cryptography.x509.BasicConstraints).critical is True + assert certificate.extensions.get_extension_for_class(cryptography.x509.BasicConstraints).value == expected_basic_constraints + + assert certificate.extensions.get_extension_for_class(cryptography.x509.KeyUsage).critical is True + assert certificate.extensions.get_extension_for_class(cryptography.x509.KeyUsage).value == expected_key_usage + + assert certificate.extensions.get_extension_for_class(cryptography.x509.ExtendedKeyUsage).critical is True + assert certificate.extensions.get_extension_for_class(cryptography.x509.ExtendedKeyUsage).value == expected_extended_key_usage + + +def test_issue_client_certificate_has_correct_public_key(): + ca_hierarchy = gimmecert.crypto.generate_ca_hierarchy('My Project', 1) + issuer_private_key, issuer_certificate = ca_hierarchy[0] + + private_key = gimmecert.crypto.generate_private_key() + + certificate = gimmecert.crypto.issue_client_certificate('myclient', private_key.public_key(), issuer_private_key, issuer_certificate) + + assert certificate.public_key().public_numbers() == private_key.public_key().public_numbers() + + +@freeze_time('2018-01-01 00:15:00') +def test_issue_client_certificate_not_before_is_15_minutes_in_past(): + ca_hierarchy = gimmecert.crypto.generate_ca_hierarchy('My Project', 1) + issuer_private_key, issuer_certificate = ca_hierarchy[0] + + private_key = gimmecert.crypto.generate_private_key() + + certificate = gimmecert.crypto.issue_client_certificate('myclient', private_key.public_key(), issuer_private_key, issuer_certificate) + + assert certificate.not_valid_before == datetime.datetime(2018, 1, 1, 0, 0) + + +def test_issue_client_certificate_not_before_does_not_exceed_ca_validity(): + with freeze_time('2018-01-01 00:15:00'): + ca_hierarchy = gimmecert.crypto.generate_ca_hierarchy('My Project', 1) + + issuer_private_key, issuer_certificate = ca_hierarchy[0] + + private_key = gimmecert.crypto.generate_private_key() + + with freeze_time(issuer_certificate.not_valid_before - datetime.timedelta(seconds=1)): + certificate1 = gimmecert.crypto.issue_client_certificate('myclient', private_key.public_key(), issuer_private_key, issuer_certificate) + + assert certificate1.not_valid_before == issuer_certificate.not_valid_before + + +def test_issue_client_certificate_not_after_does_not_exceed_ca_validity(): + with freeze_time('2018-01-01 00:15:00'): + ca_hierarchy = gimmecert.crypto.generate_ca_hierarchy('My Project', 1) + + issuer_private_key, issuer_certificate = ca_hierarchy[0] + + private_key = gimmecert.crypto.generate_private_key() + + with freeze_time(issuer_certificate.not_valid_after + datetime.timedelta(seconds=1)): + certificate1 = gimmecert.crypto.issue_client_certificate('myclient', private_key.public_key(), issuer_private_key, issuer_certificate) + + assert certificate1.not_valid_after == issuer_certificate.not_valid_after diff --git a/tests/test_storage.py b/tests/test_storage.py index 7e06cdfef3490b13515ee9b666f68cd1d421bfb7..fa0ed329c1de68c2d1db5ac0ec50a0bc45f91fa5 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -37,6 +37,7 @@ def test_initialise_storage(tmpdir): assert os.path.exists(tmpdir.join('.gimmecert').strpath) assert os.path.exists(tmpdir.join('.gimmecert', 'ca').strpath) assert os.path.exists(tmpdir.join('.gimmecert', 'server').strpath) + assert os.path.exists(tmpdir.join('.gimmecert', 'client').strpath) def test_write_private_key(tmpdir):