From 6f7ddb3a2595b01f60f31a713ca651749abb7560 Mon Sep 17 00:00:00 2001 From: Emmanuel Garette Date: Sat, 14 Nov 2020 11:50:56 +0100 Subject: [PATCH] WIP: add OpenSSH plugin --- docs/production/index.rst | 121 ++++++++++++++++++- lemur/authorities/schemas.py | 9 +- lemur/certificates/schemas.py | 13 ++ lemur/certificates/service.py | 10 ++ lemur/certificates/views.py | 10 ++ lemur/plugins/bases/issuer.py | 9 ++ lemur/plugins/lemur_openssh/__init__.py | 4 + lemur/plugins/lemur_openssh/plugin.py | 152 ++++++++++++++++++++++++ setup.py | 3 +- 9 files changed, 328 insertions(+), 3 deletions(-) create mode 100644 lemur/plugins/lemur_openssh/__init__.py create mode 100644 lemur/plugins/lemur_openssh/plugin.py diff --git a/docs/production/index.rst b/docs/production/index.rst index c6f561ca..57800bd8 100644 --- a/docs/production/index.rst +++ b/docs/production/index.rst @@ -554,4 +554,123 @@ Using `python-jwt` converting an existing private key in PEM format is quite eas {"body": {}, "uri": "https://acme-staging-v02.api.letsencrypt.org/acme/acct/"} -The URI can be retrieved from the ACME create account endpoint when creating a new account, using the existing key. \ No newline at end of file +The URI can be retrieved from the ACME create account endpoint when creating a new account, using the existing key. + +OpenSSH +======= + +OpenSSH (also known as OpenBSD Secure Shell) is a suite of secure networking utilities based on the Secure Shell (SSH) protocol, which provides a secure channel over an unsecured network in a client–server architecture. + +Using a PKI with OpenSSH means you can sign a key for a user and it can log into any server that trust the CA. + +Using a CA avoids TOFU or synchronize a list of server public keys to `known_hosts` files. + +This is useful when you're managing large number of machines or for an immutable infrastructure. + +Add first OpenSSH authority +--------------------------- + +To start issuing OpenSSH, you need to create an OpenSSH authority. To do this, visit +Authorities -> Create. Set the applicable attributes: + +- Name : OpenSSH +- Common Name: example.net + +Then click "More Options" and change the plugin value to "OpenSSH". + +Just click to "Create" button to add this authority. + +.. note:: OpenSSH do not support sub CA feature. + +Add a server certificate +------------------------- + +Now visit Certificates -> Create to add a server certificate. Set the applicable attributes: + +- Common Name: server.example.net + +Then click "More Options" and set the Certificate Template to "Server Certificate". + +This step is important, a certificat for a server and for a client is not exactly the same thing. +In this case "Common Name" and all Subject Alternate Names with type DNSName will be added in the certificate. + +Finally click on "Create" button. + +Add a client certificate +------------------------ + +Now visit Certificates -> Create to add a client certificate. Set the applicable attributes: + +- Common Name: example.net + +Then click "More Options" and set the Certificate Template to "Client Certificate". + +In this case the name of the creator is used as principal (in this documentation we assume that this certificate is created by the user "lemur"). + +Finally click on "Create" button. + +Configure OpenSSH server +------------------------ + +Connect to the server.example.net server to correctly configure the OpenSSH server with the CA created previously. + +First of all add the CA chain, private and public certificates: + +- Create file `/etc/ssh/ca.pub` and copy the "CHAIN" content of the *server certificate* (everything in one line). +- Create file `/etc/ssh/ssh_host_key` and copy "PRIVATE KEY" content. +- Create file `/etc/ssh/ssh_host_key.pub` and copy "PUBLIC CERTIFICATE" content (everything in one line). + +Set the appropriate right: + +.. code-block:: bash + + chmod 600 /etc/ssh/ca.pub /etc/ssh/ssh_host_key + chmod 644 /etc/ssh/ssh_host_key.pub + chown root: /etc/ssh/ca.pub /etc/ssh/ssh_host_key /etc/ssh/ssh_host_key.pub + +Then change OpenSSH server configuration to use these files. Edit `/etc/ssh/sshd_config` and add:: + + TrustedUserCAKeys /etc/ssh/ca.pub + HostKey /etc/ssh/ssh_host_key + HostCertificate /etc/ssh/ssh_host_key.pub + +You can remove all other `HostKey` lines. + +Finally restart OpenSSH. + +.. note:: By default the server public certificate is sign for 2 weeks. You must update the `/etc/ssh/ssh_host_key.pub` file before this delay. You can use the config's parameter OPENSSH_VALID_INTERVAL_SERVER to change this behavor (unit is number of day). + +Configure the OpenSSH client +---------------------------- + +Now you can configure the user's computer. + +First of all add private and public certificates: + +- Create file `~/.ssh/key` and copy "PRIVATE KEY" content. +- Create file `~/.ssh/key.pub` and copy "PUBLIC CERTIFICATE" content of the *client certicate* (everything in one line). + +Set the appropriate right: + +.. code-block:: bash + + chmod 600 ~/.ssh/key.pub ~/.ssh/key + +To avoid TOFU, edite the `~/.ssh/known_hosts` file and add a new line (all in one line): + +- @cert-authority \*example.net +- the "CHAIN" content + +Now you can connect to server with (here 'lemur' is the principal name and must exists on the server): + +.. code-block:: bash + + ssh lemur@server.example.net -i ~/.ssh/key + +With this configuration you don't have any line like:: + + Warning: Permanently added 'server.example.net,192.168.0.1' (RSA) to the list of known hosts. + +And you don't have to enter any password. + +.. note:: By default the client public certificate is sign for 1 day. You must update the `.ssh/key.pub` everyday. You can use the config's parameter OPENSSH_VALID_INTERVAL_CLIENT to change this behavor (unit is number of day). diff --git a/lemur/authorities/schemas.py b/lemur/authorities/schemas.py index 555ba931..225e8cd1 100644 --- a/lemur/authorities/schemas.py +++ b/lemur/authorities/schemas.py @@ -7,7 +7,7 @@ """ from flask import current_app -from marshmallow import fields, validates_schema, pre_load +from marshmallow import fields, validates_schema, pre_load, post_dump from marshmallow import validate from marshmallow.exceptions import ValidationError @@ -24,6 +24,7 @@ from lemur.common import validators, missing from lemur.common.fields import ArrowDateTime from lemur.constants import CERTIFICATE_KEY_TYPES +from lemur.plugins.base import plugins class AuthorityInputSchema(LemurInputSchema): @@ -129,6 +130,12 @@ class AuthorityOutputSchema(LemurOutputSchema): default_validity_days = fields.Integer() authority_certificate = fields.Nested(RootAuthorityCertificateOutputSchema) + @post_dump + def handle_auth_certificate(self, cert): + # Plugins may need to modify the cert object before returning it to the user + plugin = plugins.get(cert['plugin']['slug']) + plugin.wrap_auth_certificate(cert['authority_certificate']) + class AuthorityNestedOutputSchema(LemurOutputSchema): __envelope__ = False diff --git a/lemur/certificates/schemas.py b/lemur/certificates/schemas.py index 3dc864e7..6fb2c15f 100644 --- a/lemur/certificates/schemas.py +++ b/lemur/certificates/schemas.py @@ -38,6 +38,7 @@ from lemur.schemas import ( AssociatedRotationPolicySchema, ) from lemur.users.schemas import UserNestedOutputSchema +from lemur.plugins.base import plugins class CertificateSchema(LemurInputSchema): @@ -324,6 +325,8 @@ class CertificateOutputSchema(LemurOutputSchema): notifications = fields.Nested(NotificationNestedOutputSchema, many=True) replaces = fields.Nested(CertificateNestedOutputSchema, many=True) authority = fields.Nested(AuthorityNestedOutputSchema) + # if this certificate is an authority, the authority informations are in root_authority + root_authority = fields.Nested(AuthorityNestedOutputSchema) dns_provider = fields.Nested(DnsProvidersNestedOutputSchema) roles = fields.Nested(RoleNestedOutputSchema, many=True) endpoints = fields.Nested(EndpointNestedOutputSchema, many=True, missing=[]) @@ -357,6 +360,16 @@ class CertificateOutputSchema(LemurOutputSchema): if field in data and data[field] is None: data.pop(field) + @post_dump + def handle_certificate(self, cert): + # Plugins may need to modify the cert object before returning it to the user + if cert['root_authority'] and cert['authority'] is None: + # this certificate is an authority + cert['authority'] = cert['root_authority'] + del cert['root_authority'] + plugin = plugins.get(cert['authority']['plugin']['slug']) + plugin.wrap_certificate(cert) + class CertificateShortOutputSchema(LemurOutputSchema): id = fields.Integer() diff --git a/lemur/certificates/service.py b/lemur/certificates/service.py index ac844120..df8450d3 100644 --- a/lemur/certificates/service.py +++ b/lemur/certificates/service.py @@ -87,6 +87,16 @@ def get_by_attributes(conditions): return database.find_all(query, Certificate, conditions).all() +def get_by_root_authority(id): + """ + Retrieves certificate by its root_authority's id. + + :param id: + :return: + """ + return database.get(Certificate, id, field="root_authority_id") + + def delete(cert_id): """ Delete's a certificate. diff --git a/lemur/certificates/views.py b/lemur/certificates/views.py index a066f20f..d4f1860c 100644 --- a/lemur/certificates/views.py +++ b/lemur/certificates/views.py @@ -675,6 +675,16 @@ class CertificatePrivateKey(AuthenticatedResource): return dict(message="You are not authorized to view this key"), 403 log_service.create(g.current_user, "key_view", certificate=cert) + + # Plugins may need to modify the cert object before returning it to the user + if cert.root_authority: + # this certificate is an authority + plugin_name = cert.root_authority.plugin_name + else: + plugin_name = cert.authority.plugin_name + plugin = plugins.get(plugin_name) + plugin.wrap_private_key(cert) + response = make_response(jsonify(key=cert.private_key), 200) response.headers["cache-control"] = "private, max-age=0, no-cache, no-store" response.headers["pragma"] = "no-cache" diff --git a/lemur/plugins/bases/issuer.py b/lemur/plugins/bases/issuer.py index f1e6aa0e..659611f3 100644 --- a/lemur/plugins/bases/issuer.py +++ b/lemur/plugins/bases/issuer.py @@ -31,3 +31,12 @@ class IssuerPlugin(Plugin): def cancel_ordered_certificate(self, pending_cert, **kwargs): raise NotImplementedError + + def wrap_certificate(self, cert): + pass + + def wrap_auth_certificate(self, cert): + pass + + def wrap_private_key(self, cert): + pass diff --git a/lemur/plugins/lemur_openssh/__init__.py b/lemur/plugins/lemur_openssh/__init__.py new file mode 100644 index 00000000..f8afd7e3 --- /dev/null +++ b/lemur/plugins/lemur_openssh/__init__.py @@ -0,0 +1,4 @@ +try: + VERSION = __import__("pkg_resources").get_distribution(__name__).version +except Exception as e: + VERSION = "unknown" diff --git a/lemur/plugins/lemur_openssh/plugin.py b/lemur/plugins/lemur_openssh/plugin.py new file mode 100644 index 00000000..6a02abe0 --- /dev/null +++ b/lemur/plugins/lemur_openssh/plugin.py @@ -0,0 +1,152 @@ +""" +.. module: lemur.plugins.lemur_openssh.plugin + :platform: Unix + :copyright: (c) 2020 by Emmanuel Garette, see AUTHORS for more + :license: Apache, see LICENSE for more details. + +.. moduleauthor:: Emmanuel Garette +""" +import subprocess +from os import unlink + +from flask import current_app +from cryptography.hazmat.primitives import serialization +from datetime import datetime, timedelta + +from lemur.utils import mktempfile +from lemur.plugins import lemur_openssh as openssh +from lemur.common.utils import parse_private_key, parse_certificate +from lemur.plugins.lemur_cryptography.plugin import CryptographyIssuerPlugin +from lemur.certificates.service import get_by_root_authority + + +def run_process(command): + """ + Runs a given command with pOpen and wraps some + error handling around it. + :param command: + :return: + """ + p = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + current_app.logger.debug(" ".join(command)) + stdout, stderr = p.communicate() + + if p.returncode != 0: + current_app.logger.error(stderr.decode()) + raise Exception(stderr.decode()) + + +def split_cert(body): + """ + To display certificate in Lemur website, we have to split + certificate in several line + :param body: certificate + :retur: splitted certificate + """ + length = 65 + return '\n'.join([body[i:i + length] for i in range(0, len(body), length)]) + + +def sign_certificate(common_name, public_key, authority_private_key, user, extensions, not_before, not_after): + with mktempfile() as issuer_tmp: + cmd = ['ssh-keygen', '-s', issuer_tmp] + with open(issuer_tmp, 'w') as i: + i.writelines(authority_private_key) + if 'extendedKeyUsage' in extensions and extensions['extendedKeyUsage'].get('useClientAuthentication'): + valid_interval = current_app.config.get("OPENSSH_VALID_INTERVAL_CLIENT", 1) # 1 day by default + cmd.extend(['-I', user['username'] + ' user key', + '-n', user['username']]) + else: + valid_interval = current_app.config.get("OPENSSH_VALID_INTERVAL_SERVER", 14) # 2 weeks by default + domains = {common_name} + for name in extensions['subAltNames']['names']: + if name['nameType'] == 'DNSName': + domains.add(name['value']) + cmd.extend(['-I', common_name + ' host key', + '-n', ','.join(domains), + '-h']) + # something like 20201024 + ssh_not_before = datetime.fromisoformat(not_before).strftime("%Y%m%d") + cert_not_after = datetime.fromisoformat(not_after).strftime("%Y%m%d") + ssh_not_after = (datetime.now() + timedelta(days=valid_interval)).strftime("%Y%m%d") + ssh_not_after = min(ssh_not_after, cert_not_after) + cmd.extend(['-V', ssh_not_before + ':' + ssh_not_after]) + with mktempfile() as cert_tmp: + with open(cert_tmp, 'w') as f: + f.write(public_key) + + cmd.append(cert_tmp) + run_process(cmd) + pub = cert_tmp + '-cert.pub' + with open(pub, 'r') as p: + body = split_cert(p.read()) + unlink(pub) + return body + + +class OpenSSHIssuerPlugin(CryptographyIssuerPlugin): + """This issuer plugins is base in Cryptography plugin + Certificates and authorities are x509 certificates created by Cryptography plugin. + Those certificates are converted to OpenSSH format when people get them. + """ + title = "OpenSSH" + slug = "openssh-issuer" + description = "Enables the creation and signing OpenSSH keys" + version = openssh.VERSION + + author = "Emmanuel Garette" + author_url = "http://gnunux.info" + + def create_authority(self, options): + # OpenSSH do not support parent's authoriy + if options.get("parent"): + raise Exception('cannot create authority with a parent for OpenSSH plugin') + # create a x509 certificat + cert_pem, private_key, chain_cert_pem, roles = super().create_authority(options) + return cert_pem, private_key, chain_cert_pem, roles + + def wrap_certificate(self, cert): + # get public_key in OpenSSH format + public_key = parse_certificate(cert['body']).public_key().public_bytes( + encoding=serialization.Encoding.OpenSSH, + format=serialization.PublicFormat.OpenSSH, + ).decode() + public_key += ' ' + cert['user']['email'] + # sign it with authority private key + authority = get_by_root_authority(cert['authority']['id']) + authority_private_key = authority.private_key + cert['body'] = sign_certificate( + cert['common_name'], + public_key, + authority_private_key, + cert['user'], + cert['extensions'], + cert['not_before'], + cert['not_after'] + ) + # convert chain in OpenSSH format + if cert['chain']: + chain_cert = {'body': cert['chain'], 'cn': authority.cn} + self.wrap_auth_certificate(chain_cert) + cert['chain'] = chain_cert['body'] + # OpenSSH do not support csr + cert['csr'] = None + + @staticmethod + def wrap_auth_certificate(auth_cert): + # convert chain in OpenSSH format + chain_key = parse_certificate(auth_cert['body']).public_key().public_bytes( + encoding=serialization.Encoding.OpenSSH, + format=serialization.PublicFormat.OpenSSH, + ).decode() + chain_key += ' root@' + auth_cert['cn'] + auth_cert['body'] = split_cert(chain_key) + + @staticmethod + def wrap_private_key(cert): + # convert private_key in OpenSSH format + cert.private_key = parse_private_key(cert.private_key).private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.OpenSSH, + encryption_algorithm=serialization.NoEncryption(), + ) diff --git a/setup.py b/setup.py index 59e35b53..caa6bc04 100644 --- a/setup.py +++ b/setup.py @@ -157,7 +157,8 @@ setup( 'adcs_issuer = lemur.plugins.lemur_adcs.plugin:ADCSIssuerPlugin', 'adcs_source = lemur.plugins.lemur_adcs.plugin:ADCSSourcePlugin', 'entrust_issuer = lemur.plugins.lemur_entrust.plugin:EntrustIssuerPlugin', - 'entrust_source = lemur.plugins.lemur_entrust.plugin:EntrustSourcePlugin' + 'entrust_source = lemur.plugins.lemur_entrust.plugin:EntrustSourcePlugin', + 'openssh_issuer = lemur.plugins.lemur_openssh.plugin:OpenSSHIssuerPlugin', ], }, classifiers=[