Changeset - 2450d422e8af
[Not reviewed]
0 5 0
Branko Majic (branko) - 6 years ago 2018-03-21 21:21:08
branko@majic.rs
GC-19: Added option for updating server certificate DNS names:

- Added functional test covering the new scenario.
- Added option to server command for updating DNS names for already
issued certificate. Private key is kept for this purpose.
- Implemented unit tests.
- Fixed functional test related to viewing short usage instructions.
5 files changed with 183 insertions and 10 deletions:
0 comments (0 inline, 0 general)
functional_tests/test_server.py
Show inline comments
 
@@ -42,7 +42,7 @@ def test_server_command_available_with_help():
 
    assert exit_code == 0
 
    assert stderr == ""
 
    assert stdout.startswith("usage: gimmecert server")
 
    assert stdout.split('\n')[0].endswith(" entity_name [dns_name [dns_name ...]]")  # First line of help.
 
    assert " entity_name [dns_name [dns_name ...]]" in stdout
 

	
 

	
 
def test_server_command_requires_initialised_hierarchy(tmpdir):
 
@@ -202,3 +202,73 @@ def test_server_command_does_not_overwrite_existing_artifacts(tmpdir):
 
    # unchanged.
 
    assert tmpdir.join(".gimmecert", "server", "myserver.key.pem").read() == private_key
 
    assert tmpdir.join(".gimmecert", "server", "myserver.cert.pem").read() == certificate
 

	
 

	
 
def test_server_command_update_option(tmpdir):
 
    # John is in a bit of a rush to get his project going. Since he
 
    # needs a server certificate issued, he goes ahead and quickly
 
    # initialises CA and issues a single server certificate, with
 
    # intention of accessing the service via URL
 
    # https://myservice.example.com/.
 
    tmpdir.chdir()
 
    run_command("gimmecert", "init")
 
    run_command("gimmecert", "server", "myserver1", "mysercive.example.com")
 

	
 
    # Once he imports the CA certificate into his browser, and tries
 
    # to access the service page, he very quickly finds out that he
 
    # has misspelled "myservice". Just to be on the safe side, he has
 
    # a look at the certificate using the OpenSSL CLI.
 
    stdout, stderr, exit_code = run_command('openssl', 'x509', '-noout', '-text', '-in', '.gimmecert/server/myserver1.cert.pem')
 

	
 
    # And indeed, in addition to his server name, he can see that the
 
    # extra DNS subject alternative name he provided is wrong.
 
    assert "DNS:myserver1," in stdout
 
    assert "DNS:mysercive.example.com\n" in stdout
 
    assert "DNS:myservice.example.com" not in stdout
 

	
 
    # Not sure what to do, he takes a quick look at help for the
 
    # server command.
 
    stdout, stderr, exit_code = run_command("gimmecert", "server", "-h")
 

	
 
    # He notices there is an option for updating an DNS subject
 
    # alternative names for a server certificate.
 
    assert " --update-dns-names" in stdout
 
    assert " -u\n" in stdout
 

	
 
    # Based on help description, this seems to be exactly what he
 
    # needs. Private key is still fine, but his certificate needs to
 
    # be reissued. He goes ahead and runs the command for updating the
 
    # DNS subject alternative names.
 
    stdout, stderr, exit_code = run_command("gimmecert", "server", "--update-dns-names", "myserver1", "myservice.example.com")
 

	
 
    # He notices that no error has been reported by the command, and
 
    # that he is informed that the certificate has been renewed with
 
    # new DNS names.
 
    assert exit_code == 0
 
    assert "renewed with new DNS subject alternative names" in stdout
 

	
 
    # Being paranoid, he decides to double-check the certificate, just
 
    # to be on the safe side. He uses the OpenSSL CLI for this
 
    # purpose.
 
    stdout, stderr, exit_code = run_command('openssl', 'x509', '-noout', '-text', '-in', '.gimmecert/server/myserver1.cert.pem')
 

	
 
    # He notices that certificate includes the intended naming. He can
 
    # finally move ahead with his project.
 
    assert "DNS:myserver1," in stdout
 
    assert "DNS:myservice.example.com\n" in stdout
 

	
 
    # John now needs another server certificate. Being a bit lazy, he
 
    # goes up in his shell history, changes the server name, and
 
    # reruns the command.
 
    stdout, stderr, exit_code = run_command("gimmecert", "server", "--update-dns-names", "myserver2", "myservice.example.com")
 

	
 
    # Just as he presses enter, he realizes that he has run the
 
    # command with --update-dns-names option, even though he has not
 
    # issued this particular server certificate before. Despite this,
 
    # no errors are reported, and he gets the familiar message about
 
    # server certificate being issued. Cool!
 
    assert stderr == ""
 
    assert exit_code == 0
 
    assert "Server certificate issued." in stdout
 
    assert ".gimmecert/server/myserver2.key.pem" in stdout
 
    assert ".gimmecert/server/myserver2.cert.pem" in stdout
gimmecert/cli.py
Show inline comments
 
@@ -88,11 +88,14 @@ def setup_server_subcommand_parser(parser, subparsers):
 
    subparser = subparsers.add_parser('server', description='Issues server certificate.')
 
    subparser.add_argument('entity_name', help='Name of the server entity.')
 
    subparser.add_argument('dns_name', nargs='*', help='Additional DNS names to include in subject alternative name.')
 
    subparser.add_argument('--update-dns-names', '-u', action='store_true', help='''Renew certificate for an existing server entity by reusing \
 
    the private key, but replacing the DNS subject alternative names with listed values (if any). \
 
    If entity does not exist, this option has no effect, and a new private key/certificate will be generated as usual.''')
 

	
 
    def server_wrapper(args):
 
        project_directory = os.getcwd()
 

	
 
        return server(sys.stdout, sys.stderr, project_directory, args.entity_name, args.dns_name)
 
        return server(sys.stdout, sys.stderr, project_directory, args.entity_name, args.dns_name, args.update_dns_names)
 

	
 
    subparser.set_defaults(func=server_wrapper)
 

	
gimmecert/commands.py
Show inline comments
 
@@ -94,7 +94,7 @@ def init(stdout, stderr, project_directory, ca_base_name, ca_hierarchy_depth):
 
    return ExitCode.SUCCESS
 

	
 

	
 
def server(stdout, stderr, project_directory, entity_name, extra_dns_names):
 
def server(stdout, stderr, project_directory, entity_name, extra_dns_names, update_dns_names=False):
 
    """
 
    Generates a server private key and issues a server certificate
 
    using the CA hierarchy initialised within the specified directory.
 
@@ -114,10 +114,15 @@ def server(stdout, stderr, project_directory, entity_name, extra_dns_names):
 
    :param extra_dns_names: List of additional DNS names to include in the subject alternative name.
 
    :type extra_dns_names: list[str]
 

	
 
    :param update_dns_names: Whether the certificate should be renewed using the existing private key, but with new DNS subject alternative names.
 
    :type update: bool
 

	
 
    :returns: Status code, one from gimmecert.commands.ExitCode.
 
    :rtype: int
 
    """
 

	
 
    renew_certificate_only = False
 

	
 
    private_key_path = os.path.join(project_directory, '.gimmecert', 'server', '%s.key.pem' % entity_name)
 
    certificate_path = os.path.join(project_directory, '.gimmecert', 'server', '%s.cert.pem' % entity_name)
 

	
 
@@ -125,22 +130,33 @@ def server(stdout, stderr, project_directory, entity_name, extra_dns_names):
 
        print("CA hierarchy must be initialised prior to issuing server certificates. Run the gimmecert init command first.", file=stderr)
 
        return ExitCode.ERROR_NOT_INITIALISED
 

	
 
    if os.path.exists(private_key_path) or os.path.exists(certificate_path):
 
    if not update_dns_names and (os.path.exists(private_key_path) or os.path.exists(certificate_path)):
 
        print("Refusing to overwrite existing data. Certificate has already been issued for server %s." % entity_name, file=stderr)
 
        return ExitCode.ERROR_CERTIFICATE_ALREADY_ISSUED
 

	
 
    print("""Server certificate issued.\n
 
    Server private key: .gimmecert/server/%s.key.pem
 
    Server certificate: .gimmecert/server/%s.cert.pem""" % (entity_name, entity_name), file=stdout)
 
    if update_dns_names and os.path.exists(private_key_path):
 
        renew_certificate_only = True
 
        private_key = gimmecert.storage.read_private_key(private_key_path)
 
    else:
 
        private_key = gimmecert.crypto.generate_private_key()
 

	
 
    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_server_certificate(entity_name, private_key.public_key(), issuer_private_key, issuer_certificate, extra_dns_names)
 

	
 
    gimmecert.storage.write_private_key(private_key, private_key_path)
 
    gimmecert.storage.write_certificate(certificate, certificate_path)
 

	
 
    if renew_certificate_only:
 
        print("""Server certificate renewed with new DNS subject alternative names.\n
 
        Server private key: .gimmecert/server/%s.key.pem
 
        Server certificate: .gimmecert/server/%s.cert.pem""" % (entity_name, entity_name), file=stdout)
 
    else:
 
        print("""Server certificate issued.\n
 
        Server private key: .gimmecert/server/%s.key.pem
 
        Server certificate: .gimmecert/server/%s.cert.pem""" % (entity_name, entity_name), file=stdout)
 

	
 
    return ExitCode.SUCCESS
 

	
 

	
tests/test_cli.py
Show inline comments
 
@@ -365,7 +365,7 @@ def test_server_command_invoked_with_correct_parameters_without_extra_dns_names(
 

	
 
    gimmecert.cli.main()
 

	
 
    mock_server.assert_called_once_with(sys.stdout, sys.stderr, tmpdir.strpath, 'myserver', [])
 
    mock_server.assert_called_once_with(sys.stdout, sys.stderr, tmpdir.strpath, 'myserver', [], False)
 

	
 

	
 
@mock.patch('sys.argv', ['gimmecert', 'server', 'myserver', 'service.local', 'service.example.com'])
 
@@ -379,7 +379,7 @@ def test_server_command_invoked_with_correct_parameters_with_extra_dns_names(moc
 

	
 
    gimmecert.cli.main()
 

	
 
    mock_server.assert_called_once_with(sys.stdout, sys.stderr, tmpdir.strpath, 'myserver', ['service.local', 'service.example.com'])
 
    mock_server.assert_called_once_with(sys.stdout, sys.stderr, tmpdir.strpath, 'myserver', ['service.local', 'service.example.com'], False)
 

	
 

	
 
@mock.patch('sys.argv', ['gimmecert', 'help'])
 
@@ -533,3 +533,41 @@ 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, 'myclient')
 

	
 

	
 
@mock.patch('sys.argv', ['gimmecert', 'server', '--update-dns-names', 'myserver'])
 
@mock.patch('gimmecert.cli.server')
 
def test_server_command_accepts_update_option_long_form(mock_server, tmpdir):
 
    # This should ensure we don't accidentally create artifacts
 
    # outside of test directory.
 
    tmpdir.chdir()
 

	
 
    mock_server.return_value = gimmecert.commands.ExitCode.SUCCESS
 

	
 
    gimmecert.cli.main()  # Should not raise
 

	
 

	
 
@mock.patch('sys.argv', ['gimmecert', 'server', '-u', 'myserver'])
 
@mock.patch('gimmecert.cli.server')
 
def test_server_command_accepts_update_option_short_form(mock_server, tmpdir):
 
    # This should ensure we don't accidentally create artifacts
 
    # outside of test directory.
 
    tmpdir.chdir()
 

	
 
    mock_server.return_value = gimmecert.commands.ExitCode.SUCCESS
 

	
 
    gimmecert.cli.main()  # Should not raise
 

	
 

	
 
@mock.patch('sys.argv', ['gimmecert', 'server', '--update-dns-names', 'myserver', 'service.local'])
 
@mock.patch('gimmecert.cli.server')
 
def test_server_command_invoked_with_correct_parameters_with_update_option(mock_server, tmpdir):
 
    # This should ensure we don't accidentally create artifacts
 
    # outside of test directory.
 
    tmpdir.chdir()
 

	
 
    mock_server.return_value = gimmecert.commands.ExitCode.SUCCESS
 

	
 
    gimmecert.cli.main()
 

	
 
    mock_server.assert_called_once_with(sys.stdout, sys.stderr, tmpdir.strpath, 'myserver', ['service.local'], True)
tests/test_commands.py
Show inline comments
 
@@ -415,3 +415,49 @@ def test_client_errors_out_if_certificate_already_issued(tmpdir):
 
    assert stdout == ""
 
    assert tmpdir.join('.gimmecert', 'client', 'myclient.key.pem').read() == existing_private_key
 
    assert tmpdir.join('.gimmecert', 'client', 'myclient.cert.pem').read() == certificate
 

	
 

	
 
def test_server_reports_success_if_certificate_already_issued_but_update_was_requested(tmpdir):
 
    depth = 1
 

	
 
    stdout_stream = io.StringIO()
 
    stderr_stream = io.StringIO()
 

	
 
    # Previous run.
 
    gimmecert.commands.init(io.StringIO(), io.StringIO(), tmpdir.strpath, tmpdir.basename, depth)
 
    gimmecert.commands.server(io.StringIO(), io.StringIO(), tmpdir.strpath, 'myserver', None)
 
    existing_private_key = tmpdir.join('.gimmecert', 'server', 'myserver.key.pem').read()
 
    certificate = tmpdir.join('.gimmecert', 'server', 'myserver.cert.pem').read()
 

	
 
    # New run.
 
    status_code = gimmecert.commands.server(stdout_stream, stderr_stream, tmpdir.strpath, 'myserver', None, True)
 

	
 
    stdout = stdout_stream.getvalue()
 
    stderr = stderr_stream.getvalue()
 

	
 
    assert status_code == gimmecert.commands.ExitCode.SUCCESS
 
    assert ".gimmecert/server/myserver.key.pem" in stdout
 
    assert ".gimmecert/server/myserver.cert.pem" in stdout
 
    assert "renewed with new DNS subject alternative names" in stdout
 
    assert stderr == ""
 
    assert tmpdir.join('.gimmecert', 'server', 'myserver.key.pem').read() == existing_private_key
 
    assert tmpdir.join('.gimmecert', 'server', 'myserver.cert.pem').read() != certificate
 

	
 

	
 
def test_server_reports_success_if_certificate_not_already_issued_but_update_was_requested(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.server(stdout_stream, stderr_stream, tmpdir.strpath, 'myserver', None, True)
 

	
 
    stdout = stdout_stream.getvalue()
 
    stderr = stderr_stream.getvalue()
 

	
 
    assert status_code == gimmecert.commands.ExitCode.SUCCESS
 
    assert ".gimmecert/server/myserver.key.pem" in stdout
 
    assert ".gimmecert/server/myserver.cert.pem" in stdout
 
    assert stderr == ""
0 comments (0 inline, 0 general)