certidude/certidude/authority.py

360 lines
13 KiB
Python

import click
import os
import random
import re
import requests
import socket
from datetime import datetime, timedelta
from cryptography.hazmat.backends import default_backend
from cryptography import x509
from cryptography.x509.oid import NameOID, ExtensionOID, AuthorityInformationAccessOID
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import hashes, serialization
from certidude import config, push, mailer, const
from certidude.wrappers import Certificate, Request
from certidude import errors
RE_HOSTNAME = "^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])(@(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9]))?$"
# https://securityblog.redhat.com/2014/06/18/openssl-privilege-separation-analysis/
# https://jamielinux.com/docs/openssl-certificate-authority/
# http://pycopia.googlecode.com/svn/trunk/net/pycopia/ssl/certs.py
# Cache CA certificate
certificate = Certificate(open(config.AUTHORITY_CERTIFICATE_PATH))
def publish_certificate(func):
# TODO: Implement e-mail and nginx notifications using hooks
def wrapped(csr, *args, **kwargs):
cert = func(csr, *args, **kwargs)
assert isinstance(cert, Certificate), "notify wrapped function %s returned %s" % (func, type(cert))
if cert.given_name and cert.surname and cert.email_address:
recipient = "%s %s <%s>" % (cert.given_name, cert.surname, cert.email_address)
elif cert.email_address:
recipient = cert.email_address
else:
recipient = None
mailer.send(
"certificate-signed.md",
to=recipient,
attachments=(cert,),
certificate=cert)
if config.PUSH_PUBLISH:
url = config.PUSH_PUBLISH % csr.fingerprint()
click.echo("Publishing certificate at %s ..." % url)
requests.post(url, data=cert.dump(),
headers={"User-Agent": "Certidude API", "Content-Type": "application/x-x509-user-cert"})
# For deleting request in the web view, use pubkey modulo
push.publish("request-signed", cert.common_name)
return cert
return wrapped
def get_request(common_name):
if not re.match(RE_HOSTNAME, common_name):
raise ValueError("Invalid common name %s" % repr(common_name))
return Request(open(os.path.join(config.REQUESTS_DIR, common_name + ".pem")))
def get_signed(common_name):
if not re.match(RE_HOSTNAME, common_name):
raise ValueError("Invalid common name %s" % repr(common_name))
return Certificate(open(os.path.join(config.SIGNED_DIR, common_name + ".pem")))
def get_revoked(common_name):
if not re.match(RE_HOSTNAME, common_name):
raise ValueError("Invalid common name %s" % repr(common_name))
return Certificate(open(os.path.join(config.SIGNED_DIR, common_name + ".pem")))
def store_request(buf, overwrite=False):
"""
Store CSR for later processing
"""
if not buf: return # No certificate supplied
csr = x509.load_pem_x509_csr(buf, backend=default_backend())
for name in csr.subject:
if name.oid == NameOID.COMMON_NAME:
common_name = name.value
break
else:
raise ValueError("No common name in %s" % csr.subject)
request_path = os.path.join(config.REQUESTS_DIR, common_name + ".pem")
if not re.match(RE_HOSTNAME, common_name):
raise ValueError("Invalid common name")
# If there is cert, check if it's the same
if os.path.exists(request_path):
if open(request_path).read() == buf:
raise errors.RequestExists("Request already exists")
else:
raise errors.DuplicateCommonNameError("Another request with same common name already exists")
else:
with open(request_path + ".part", "w") as fh:
fh.write(buf)
os.rename(request_path + ".part", request_path)
req = Request(open(request_path))
mailer.send("request-stored.md", attachments=(req,), request=req)
return req
def signer_exec(cmd, *bits):
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.connect(const.SIGNER_SOCKET_PATH)
sock.send(cmd.encode("ascii"))
sock.send(b"\n")
for bit in bits:
sock.send(bit.encode("ascii"))
sock.sendall(b"\n\n")
buf = sock.recv(8192)
if not buf:
raise
return buf
def revoke_certificate(common_name):
"""
Revoke valid certificate
"""
cert = get_signed(common_name)
revoked_filename = os.path.join(config.REVOKED_DIR, "%s.pem" % cert.serial_number)
os.rename(cert.path, revoked_filename)
push.publish("certificate-revoked", cert.common_name)
mailer.send("certificate-revoked.md", attachments=(cert,), certificate=cert)
def list_requests(directory=config.REQUESTS_DIR):
for filename in os.listdir(directory):
if filename.endswith(".pem"):
yield Request(open(os.path.join(directory, filename)))
def list_signed(directory=config.SIGNED_DIR):
for filename in os.listdir(directory):
if filename.endswith(".pem"):
yield Certificate(open(os.path.join(directory, filename)))
def list_revoked(directory=config.REVOKED_DIR):
for filename in os.listdir(directory):
if filename.endswith(".pem"):
yield Certificate(open(os.path.join(directory, filename)))
def export_crl():
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.connect(const.SIGNER_SOCKET_PATH)
sock.send(b"export-crl\n")
for filename in os.listdir(config.REVOKED_DIR):
if not filename.endswith(".pem"):
continue
serial_number = filename[:-4]
# TODO: Assert serial against regex
revoked_path = os.path.join(config.REVOKED_DIR, filename)
# TODO: Skip expired certificates
s = os.stat(revoked_path)
sock.send(("%s:%d\n" % (serial_number, s.st_ctime)).encode("ascii"))
sock.sendall(b"\n")
return sock.recv(32*1024*1024)
def delete_request(common_name):
# Validate CN
if not re.match(RE_HOSTNAME, common_name):
raise ValueError("Invalid common name")
path = os.path.join(config.REQUESTS_DIR, common_name + ".pem")
request = Request(open(path))
os.unlink(path)
# Publish event at CA channel
push.publish("request-deleted", request.common_name)
# Write empty certificate to long-polling URL
requests.delete(config.PUSH_PUBLISH % request.fingerprint(),
headers={"User-Agent": "Certidude API"})
def generate_pkcs12_bundle(common_name, key_size=4096, owner=None):
"""
Generate private key, sign certificate and return PKCS#12 bundle
"""
# Construct private key
click.echo("Generating %d-bit RSA key..." % key_size)
key = rsa.generate_private_key(
public_exponent=65537,
key_size=4096,
backend=default_backend()
)
csr = x509.CertificateSigningRequestBuilder().subject_name(x509.Name([
x509.NameAttribute(k, v) for k, v in (
(NameOID.COMMON_NAME, common_name),
(NameOID.GIVEN_NAME, owner and owner.given_name),
(NameOID.SURNAME, owner and owner.surname),
) if v
]))
if owner:
click.echo("Setting e-mail to: %s" % owner.mail)
csr = csr.add_extension(
x509.SubjectAlternativeName([
x509.RFC822Name(owner.mail)
]),
critical=False)
# Sign CSR
cert = sign(Request(
csr.sign(key, hashes.SHA512(), default_backend()).public_bytes(serialization.Encoding.PEM)), overwrite=True)
# Generate P12, currently supported only by PyOpenSSL
from OpenSSL import crypto
p12 = crypto.PKCS12()
p12.set_privatekey(
crypto.load_privatekey(
crypto.FILETYPE_PEM,
key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption()
)
)
)
p12.set_certificate( cert._obj )
p12.set_ca_certificates([certificate._obj])
return p12.export(), cert
@publish_certificate
def sign(req, overwrite=False, delete=True):
"""
Sign certificate signing request via signer process
"""
cert_path = os.path.join(config.SIGNED_DIR, req.common_name + ".pem")
# Move existing certificate if necessary
if os.path.exists(cert_path):
old_cert = Certificate(open(cert_path))
if overwrite:
revoke_certificate(req.common_name)
elif req.pubkey == old_cert.pubkey:
return old_cert
else:
raise EnvironmentError("Will not overwrite existing certificate")
# Sign via signer process
cert_buf = signer_exec("sign-request", req.dump())
with open(cert_path + ".part", "wb") as fh:
fh.write(cert_buf)
os.rename(cert_path + ".part", cert_path)
return Certificate(open(cert_path))
@publish_certificate
def sign2(request, private_key, authority_certificate, overwrite=False, delete=True, lifetime=None):
"""
Sign directly using private key, this is usually done by root.
Basic constraints and certificate lifetime are copied from config,
lifetime may be overridden on the command line,
other extensions are copied as is.
"""
certificate_path = os.path.join(config.SIGNED_DIR, request.common_name + ".pem")
if os.path.exists(certificate_path):
if overwrite:
revoke_certificate(request.common_name)
else:
raise errors.DuplicateCommonNameError("Valid certificate with common name %s already exists" % request.common_name)
now = datetime.utcnow()
request_path = os.path.join(config.REQUESTS_DIR, request.common_name + ".pem")
request = x509.load_pem_x509_csr(open(request_path).read(), default_backend())
cert = x509.CertificateBuilder(
).subject_name(x509.Name([n for n in request.subject])
).serial_number(random.randint(
0x1000000000000000000000000000000000000000,
0xffffffffffffffffffffffffffffffffffffffff)
).issuer_name(authority_certificate.issuer
).public_key(request.public_key()
).not_valid_before(now - timedelta(hours=1)
).not_valid_after(now + timedelta(days=config.CERTIFICATE_LIFETIME)
).add_extension(x509.KeyUsage(
digital_signature=True,
key_encipherment=True,
content_commitment=False,
data_encipherment=False,
key_agreement=False,
key_cert_sign=False,
crl_sign=False,
encipher_only=False,
decipher_only=False), critical=True
).add_extension(
x509.SubjectKeyIdentifier.from_public_key(request.public_key()),
critical=False
).add_extension(
x509.AuthorityInformationAccess([
x509.AccessDescription(
AuthorityInformationAccessOID.CA_ISSUERS,
x509.UniformResourceIdentifier(
config.CERTIFICATE_AUTHORITY_URL)
)
]),
critical=False
).add_extension(
x509.CRLDistributionPoints([
x509.DistributionPoint(
full_name=[
x509.UniformResourceIdentifier(
config.CERTIFICATE_CRL_URL)],
relative_name=None,
crl_issuer=None,
reasons=None)
]),
critical=False
).add_extension(
x509.AuthorityKeyIdentifier.from_issuer_public_key(
authority_certificate.public_key()),
critical=False
)
# Append subject alternative name, extended key usage flags etc
for extension in request.extensions:
if extension.oid == ExtensionOID.SUBJECT_ALTERNATIVE_NAME:
click.echo("Appending subject alt name extension: %s" % extension)
cert = cert.add_extension(x509.SubjectAlternativeName(extension.value),
critical=extension.critical)
if extension.oid == ExtensionOID.EXTENDED_KEY_USAGE:
click.echo("Appending extended key usage flags extension: %s" % extension)
cert = cert.add_extension(x509.ExtendedKeyUsage(extension.value),
critical=extension.critical)
cert = cert.sign(private_key, hashes.SHA512(), default_backend())
buf = cert.public_bytes(serialization.Encoding.PEM)
with open(certificate_path + ".part", "wb") as fh:
fh.write(buf)
os.rename(certificate_path + ".part", certificate_path)
click.echo("Wrote certificate to: %s" % certificate_path)
if delete:
os.unlink(request_path)
click.echo("Deleted request: %s" % request_path)
return Certificate(open(certificate_path))