add OpenSSH plugin
This commit is contained in:
parent
da75d31fac
commit
bb5b32a435
|
@ -599,4 +599,119 @@ 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>"}
|
||||
|
||||
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 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.
|
||||
|
||||
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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,22 @@ 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['authority'] is None:
|
||||
if cert['root_authority'] is None:
|
||||
plugin = None
|
||||
else:
|
||||
# this certificate is an authority
|
||||
plugin = plugins.get(cert['root_authority']['plugin']['slug'])
|
||||
else:
|
||||
plugin = plugins.get(cert['authority']['plugin']['slug'])
|
||||
if plugin:
|
||||
plugin.wrap_certificate(cert)
|
||||
if 'root_authority' in cert:
|
||||
del cert['root_authority']
|
||||
|
||||
|
||||
class CertificateShortOutputSchema(LemurOutputSchema):
|
||||
id = fields.Integer()
|
||||
|
|
|
@ -88,6 +88,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.
|
||||
|
|
|
@ -33,6 +33,7 @@ from lemur.certificates.schemas import (
|
|||
|
||||
from lemur.roles import service as role_service
|
||||
from lemur.logs import service as log_service
|
||||
from lemur.plugins.base import plugins
|
||||
|
||||
|
||||
mod = Blueprint("certificates", __name__)
|
||||
|
@ -691,6 +692,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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
try:
|
||||
VERSION = __import__("pkg_resources").get_distribution(__name__).version
|
||||
except Exception as e:
|
||||
VERSION = "unknown"
|
|
@ -0,0 +1,159 @@
|
|||
"""
|
||||
.. 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
|
||||
|
||||
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):
|
||||
private_key = parse_private_key(authority_private_key).private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.OpenSSH,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
).decode()
|
||||
with mktempfile() as issuer_tmp:
|
||||
cmd = ['ssh-keygen', '-s', issuer_tmp]
|
||||
with open(issuer_tmp, 'w') as i:
|
||||
i.writelines(private_key)
|
||||
if 'extendedKeyUsage' in extensions and extensions['extendedKeyUsage'].get('useClientAuthentication'):
|
||||
cmd.extend(['-I', user['username'] + ' user key',
|
||||
'-n', user['username']])
|
||||
else:
|
||||
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 20201024102030
|
||||
ssh_not_before = datetime.fromisoformat(not_before).strftime("%Y%m%d%H%M%S")
|
||||
ssh_not_after = datetime.fromisoformat(not_after).strftime("%Y%m%d%H%M%S")
|
||||
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):
|
||||
if 'body' not in cert:
|
||||
return
|
||||
# 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
|
||||
if 'root_authority' in cert and cert['root_authority']:
|
||||
authority = cert['root_authority']
|
||||
else:
|
||||
authority = cert['authority']
|
||||
root_authority = get_by_root_authority(authority['id'])
|
||||
authority_private_key = root_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': root_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(),
|
||||
)
|
1
setup.py
1
setup.py
|
@ -158,6 +158,7 @@ setup(
|
|||
'adcs_source = lemur.plugins.lemur_adcs.plugin:ADCSSourcePlugin',
|
||||
'entrust_issuer = lemur.plugins.lemur_entrust.plugin:EntrustIssuerPlugin',
|
||||
'entrust_source = lemur.plugins.lemur_entrust.plugin:EntrustSourcePlugin',
|
||||
'openssh_issuer = lemur.plugins.lemur_openssh.plugin:OpenSSHIssuerPlugin',
|
||||
'azure_destination = lemur.plugins.lemur_azure_dest.plugin:AzureDestinationPlugin'
|
||||
],
|
||||
},
|
||||
|
|
Loading…
Reference in New Issue