WIP: add OpenSSH plugin

This commit is contained in:
Emmanuel Garette 2020-11-14 11:50:56 +01:00
parent 95b24cbadc
commit 6f7ddb3a25
9 changed files with 328 additions and 3 deletions

View File

@ -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/<ACCOUNT_NUMBER>"} {"body": {}, "uri": "https://acme-staging-v02.api.letsencrypt.org/acme/acct/<ACCOUNT_NUMBER>"}
The URI can be retrieved from the ACME create account endpoint when creating a new account, using the existing key. 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 clientserver 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).

View File

@ -7,7 +7,7 @@
""" """
from flask import current_app 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 import validate
from marshmallow.exceptions import ValidationError from marshmallow.exceptions import ValidationError
@ -24,6 +24,7 @@ from lemur.common import validators, missing
from lemur.common.fields import ArrowDateTime from lemur.common.fields import ArrowDateTime
from lemur.constants import CERTIFICATE_KEY_TYPES from lemur.constants import CERTIFICATE_KEY_TYPES
from lemur.plugins.base import plugins
class AuthorityInputSchema(LemurInputSchema): class AuthorityInputSchema(LemurInputSchema):
@ -129,6 +130,12 @@ class AuthorityOutputSchema(LemurOutputSchema):
default_validity_days = fields.Integer() default_validity_days = fields.Integer()
authority_certificate = fields.Nested(RootAuthorityCertificateOutputSchema) 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): class AuthorityNestedOutputSchema(LemurOutputSchema):
__envelope__ = False __envelope__ = False

View File

@ -38,6 +38,7 @@ from lemur.schemas import (
AssociatedRotationPolicySchema, AssociatedRotationPolicySchema,
) )
from lemur.users.schemas import UserNestedOutputSchema from lemur.users.schemas import UserNestedOutputSchema
from lemur.plugins.base import plugins
class CertificateSchema(LemurInputSchema): class CertificateSchema(LemurInputSchema):
@ -324,6 +325,8 @@ class CertificateOutputSchema(LemurOutputSchema):
notifications = fields.Nested(NotificationNestedOutputSchema, many=True) notifications = fields.Nested(NotificationNestedOutputSchema, many=True)
replaces = fields.Nested(CertificateNestedOutputSchema, many=True) replaces = fields.Nested(CertificateNestedOutputSchema, many=True)
authority = fields.Nested(AuthorityNestedOutputSchema) 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) dns_provider = fields.Nested(DnsProvidersNestedOutputSchema)
roles = fields.Nested(RoleNestedOutputSchema, many=True) roles = fields.Nested(RoleNestedOutputSchema, many=True)
endpoints = fields.Nested(EndpointNestedOutputSchema, many=True, missing=[]) endpoints = fields.Nested(EndpointNestedOutputSchema, many=True, missing=[])
@ -357,6 +360,16 @@ class CertificateOutputSchema(LemurOutputSchema):
if field in data and data[field] is None: if field in data and data[field] is None:
data.pop(field) 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): class CertificateShortOutputSchema(LemurOutputSchema):
id = fields.Integer() id = fields.Integer()

View File

@ -87,6 +87,16 @@ def get_by_attributes(conditions):
return database.find_all(query, Certificate, conditions).all() 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): def delete(cert_id):
""" """
Delete's a certificate. Delete's a certificate.

View File

@ -675,6 +675,16 @@ class CertificatePrivateKey(AuthenticatedResource):
return dict(message="You are not authorized to view this key"), 403 return dict(message="You are not authorized to view this key"), 403
log_service.create(g.current_user, "key_view", certificate=cert) 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 = make_response(jsonify(key=cert.private_key), 200)
response.headers["cache-control"] = "private, max-age=0, no-cache, no-store" response.headers["cache-control"] = "private, max-age=0, no-cache, no-store"
response.headers["pragma"] = "no-cache" response.headers["pragma"] = "no-cache"

View File

@ -31,3 +31,12 @@ class IssuerPlugin(Plugin):
def cancel_ordered_certificate(self, pending_cert, **kwargs): def cancel_ordered_certificate(self, pending_cert, **kwargs):
raise NotImplementedError raise NotImplementedError
def wrap_certificate(self, cert):
pass
def wrap_auth_certificate(self, cert):
pass
def wrap_private_key(self, cert):
pass

View File

@ -0,0 +1,4 @@
try:
VERSION = __import__("pkg_resources").get_distribution(__name__).version
except Exception as e:
VERSION = "unknown"

View File

@ -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 <gnunux@gnunux.info>
"""
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(),
)

View File

@ -157,7 +157,8 @@ setup(
'adcs_issuer = lemur.plugins.lemur_adcs.plugin:ADCSIssuerPlugin', 'adcs_issuer = lemur.plugins.lemur_adcs.plugin:ADCSIssuerPlugin',
'adcs_source = lemur.plugins.lemur_adcs.plugin:ADCSSourcePlugin', 'adcs_source = lemur.plugins.lemur_adcs.plugin:ADCSSourcePlugin',
'entrust_issuer = lemur.plugins.lemur_entrust.plugin:EntrustIssuerPlugin', '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=[ classifiers=[