mirror of
https://github.com/laurivosandi/certidude
synced 2024-09-28 21:11:42 +00:00
215 lines
7.9 KiB
Python
215 lines
7.9 KiB
Python
|
|
|
|
import random
|
|
import pwd
|
|
import socket
|
|
import os
|
|
import asyncore
|
|
import asynchat
|
|
from datetime import datetime
|
|
from OpenSSL import crypto
|
|
|
|
"""
|
|
Signer processes are spawned per private key.
|
|
Private key should only be readable by root.
|
|
Signer process starts up as root, reads private key,
|
|
drops privileges and awaits for opcodes (sign-request, export-crl) at UNIX domain socket
|
|
under /run/certidude/signer/
|
|
The main motivation behind the concept is to mitigate private key leaks
|
|
by confining it to a separate process.
|
|
|
|
Note that signer process uses basicConstraints, keyUsage and extendedKeyUsage
|
|
attributes from openssl.cnf via CertificateAuthority wrapper class.
|
|
Hence it's possible only to sign such certificates via the signer process,
|
|
making it hard to take advantage of hacked Certidude server, eg. being able to sign
|
|
certificate authoirty (basicConstraints=CA:TRUE) or
|
|
TLS server certificates (extendedKeyUsage=serverAuth).
|
|
"""
|
|
|
|
EXTENSION_WHITELIST = set(["subjectAltName"])
|
|
|
|
def raw_sign(private_key, ca_cert, request, basic_constraints, lifetime, key_usage=None, extended_key_usage=None):
|
|
"""
|
|
Sign certificate signing request directly with private key assuming it's readable by the process
|
|
"""
|
|
|
|
# Initialize X.509 certificate object
|
|
cert = crypto.X509()
|
|
cert.set_version(2) # This corresponds to X.509v3
|
|
|
|
# Set public key
|
|
cert.set_pubkey(request.get_pubkey())
|
|
|
|
# Set issuer
|
|
cert.set_issuer(ca_cert.get_subject())
|
|
|
|
# TODO: Assert openssl.cnf policy for subject attributes
|
|
# if request.get_subject().O != ca_cert.get_subject().O:
|
|
# raise ValueError("Orgnization name mismatch!")
|
|
# if request.get_subject().C != ca_cert.get_subject().C:
|
|
# raise ValueError("Country mismatch!")
|
|
|
|
# Copy attributes from CA
|
|
if ca_cert.get_subject().C:
|
|
cert.get_subject().C = ca_cert.get_subject().C
|
|
if ca_cert.get_subject().ST:
|
|
cert.get_subject().ST = ca_cert.get_subject().ST
|
|
if ca_cert.get_subject().L:
|
|
cert.get_subject().L = ca_cert.get_subject().L
|
|
if ca_cert.get_subject().O:
|
|
cert.get_subject().O = ca_cert.get_subject().O
|
|
|
|
# Copy attributes from request
|
|
cert.get_subject().CN = request.get_subject().CN
|
|
req_subject = request.get_subject()
|
|
if hasattr(req_subject, "OU") and req_subject.OU:
|
|
cert.get_subject().OU = req_subject.OU
|
|
|
|
# Copy e-mail, key usage, extended key from request
|
|
for extension in request.get_extensions():
|
|
cert.add_extensions([extension])
|
|
|
|
# TODO: Set keyUsage and extendedKeyUsage defaults if none has been provided in the request
|
|
|
|
# Override basic constraints if nececssary
|
|
if basic_constraints:
|
|
cert.add_extensions([
|
|
crypto.X509Extension(
|
|
b"basicConstraints",
|
|
True,
|
|
basic_constraints.encode("ascii"))])
|
|
|
|
if key_usage:
|
|
try:
|
|
cert.add_extensions([
|
|
crypto.X509Extension(
|
|
b"keyUsage",
|
|
True,
|
|
key_usage.encode("ascii"))])
|
|
except crypto.Error:
|
|
raise ValueError("Invalid value '%s' for keyUsage attribute" % key_usage)
|
|
|
|
if extended_key_usage:
|
|
cert.add_extensions([
|
|
crypto.X509Extension(
|
|
b"extendedKeyUsage",
|
|
True,
|
|
extended_key_usage.encode("ascii"))])
|
|
|
|
# Set certificate lifetime
|
|
cert.gmtime_adj_notBefore(-3600)
|
|
cert.gmtime_adj_notAfter(lifetime * 24 * 60 * 60)
|
|
|
|
# Generate serial from 0x10000000000000000000 to 0xffffffffffffffffffff
|
|
cert.set_serial_number(random.randint(
|
|
0x1000000000000000000000000000000000000000,
|
|
0xffffffffffffffffffffffffffffffffffffffff))
|
|
cert.sign(private_key, 'sha1')
|
|
return cert
|
|
|
|
|
|
class SignHandler(asynchat.async_chat):
|
|
def __init__(self, sock, server):
|
|
asynchat.async_chat.__init__(self, sock=sock)
|
|
self.buffer = []
|
|
self.set_terminator(b"\n\n")
|
|
self.server = server
|
|
|
|
def parse_command(self, cmd, body=""):
|
|
|
|
if cmd == "export-crl":
|
|
"""
|
|
Generate CRL object based on certificate serial number and revocation timestamp
|
|
"""
|
|
crl = crypto.CRL()
|
|
|
|
if body:
|
|
for line in body.split("\n"):
|
|
serial_number, timestamp = line.split(":")
|
|
# TODO: Assert serial against regex
|
|
revocation = crypto.Revoked()
|
|
revocation.set_rev_date(datetime.fromtimestamp(int(timestamp)).strftime("%Y%m%d%H%M%SZ").encode("ascii"))
|
|
revocation.set_reason(b"keyCompromise")
|
|
revocation.set_serial(serial_number.encode("ascii"))
|
|
crl.add_revoked(revocation)
|
|
|
|
self.send(crl.export(
|
|
self.server.certificate,
|
|
self.server.private_key,
|
|
crypto.FILETYPE_PEM,
|
|
self.server.revocation_list_lifetime))
|
|
|
|
elif cmd == "ocsp-request":
|
|
NotImplemented # TODO: Implement OCSP
|
|
|
|
elif cmd == "sign-request":
|
|
request = crypto.load_certificate_request(crypto.FILETYPE_PEM, body)
|
|
|
|
for e in request.get_extensions():
|
|
key = e.get_short_name().decode("ascii")
|
|
if key not in EXTENSION_WHITELIST:
|
|
raise ValueError("Certificte Signing Request contains extension '%s' which is not whitelisted" % key)
|
|
|
|
# TODO: Potential exploits during PEM parsing?
|
|
cert = raw_sign(
|
|
self.server.private_key,
|
|
self.server.certificate,
|
|
request,
|
|
basic_constraints=self.server.basic_constraints,
|
|
key_usage=self.server.key_usage,
|
|
extended_key_usage=self.server.extended_key_usage,
|
|
lifetime=self.server.lifetime)
|
|
self.send(crypto.dump_certificate(crypto.FILETYPE_PEM, cert))
|
|
else:
|
|
raise NotImplementedError("Unknown command: %s" % cmd)
|
|
|
|
self.close_when_done()
|
|
|
|
def found_terminator(self):
|
|
args = (b"".join(self.buffer)).decode("ascii").split("\n", 1)
|
|
self.parse_command(*args)
|
|
self.buffer = []
|
|
|
|
def collect_incoming_data(self, data):
|
|
self.buffer.append(data)
|
|
|
|
|
|
class SignServer(asyncore.dispatcher):
|
|
def __init__(self, socket_path, private_key, certificate, lifetime, basic_constraints, key_usage, extended_key_usage, revocation_list_lifetime):
|
|
asyncore.dispatcher.__init__(self)
|
|
|
|
# Bind to sockets
|
|
if os.path.exists(socket_path):
|
|
os.unlink(socket_path)
|
|
os.umask(0o007)
|
|
self.create_socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
self.bind(socket_path)
|
|
self.listen(5)
|
|
|
|
# Load CA private key and certificate
|
|
self.private_key = crypto.load_privatekey(crypto.FILETYPE_PEM, open(private_key).read())
|
|
self.certificate = crypto.load_certificate(crypto.FILETYPE_PEM, open(certificate).read())
|
|
self.lifetime = lifetime
|
|
self.revocation_list_lifetime = revocation_list_lifetime
|
|
self.basic_constraints = basic_constraints
|
|
self.key_usage = key_usage
|
|
self.extended_key_usage = extended_key_usage
|
|
|
|
|
|
# Perhaps perform chroot as well, currently results in
|
|
# (<class 'OpenSSL.crypto.Error'>:[('random number generator', 'SSLEAY_RAND_BYTES', 'PRNG not seeded')
|
|
# probably needs partially populated /dev in chroot
|
|
|
|
# Dropping privileges
|
|
_, _, uid, gid, gecos, root, shell = pwd.getpwnam("nobody")
|
|
#os.chroot("/run/certidude/signer/jail")
|
|
os.setgid(gid)
|
|
os.setuid(uid)
|
|
|
|
def handle_accept(self):
|
|
pair = self.accept()
|
|
if pair is not None:
|
|
sock, addr = pair
|
|
handler = SignHandler(sock, self)
|
|
|