Changeset - a6b448968a50
[Not reviewed]
0 9 0
Branko Majic (branko) - 6 years ago 2018-03-18 11:02:00
branko@majic.rs
GC-16: Implemented issuance of client certificates:

- Added functional test covering basic issuance of client
certificates.
- Replaced the dummy command implementation.
- Added new crypto function that can be used for issuing TLS client
certificates.
- Implemented relevant unit tests.
9 files changed with 337 insertions and 5 deletions:
0 comments (0 inline, 0 general)
functional_tests/test_client.py
Show inline comments
 
@@ -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
gimmecert/cli.py
Show inline comments
 
@@ -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)
 

	
gimmecert/commands.py
Show inline comments
 
@@ -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
gimmecert/crypto.py
Show inline comments
 
@@ -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
gimmecert/storage.py
Show inline comments
 
@@ -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):
tests/test_cli.py
Show inline comments
 
@@ -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')
tests/test_commands.py
Show inline comments
 
@@ -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')
tests/test_crypto.py
Show inline comments
 
@@ -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
tests/test_storage.py
Show inline comments
 
@@ -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):
0 comments (0 inline, 0 general)