certidude/certidude/helpers.py

241 lines
11 KiB
Python

import click
import os
import requests
import subprocess
import tempfile
from certidude import errors
from certidude.wrappers import Certificate, Request
from cryptography import x509
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.serialization import Encoding
from cryptography.x509.oid import NameOID, ExtendedKeyUsageOID, AuthorityInformationAccessOID
from configparser import ConfigParser
from OpenSSL import crypto
def certidude_request_certificate(server, key_path, request_path, certificate_path, authority_path, revocations_path, common_name, extended_key_usage_flags=None, org_unit=None, email_address=None, given_name=None, surname=None, autosign=False, wait=False, ip_address=None, dns=None, bundle=False, insecure=False):
"""
Exchange CSR for certificate using Certidude HTTP API server
"""
# Set up URL-s
request_params = set()
if autosign:
request_params.add("autosign=true")
if wait:
request_params.add("wait=forever")
# Expand ca.example.com
scheme = "http" if insecure else "https" # TODO: Expose in CLI
authority_url = "%s://%s/api/certificate/" % (scheme, server)
request_url = "%s://%s/api/request/" % (scheme, server)
revoked_url = "%s://%s/api/revoked/" % (scheme, server)
if request_params:
request_url = request_url + "?" + "&".join(request_params)
if os.path.exists(authority_path):
click.echo("Found authority certificate in: %s" % authority_path)
else:
click.echo("Attempting to fetch authority certificate from %s" % authority_url)
try:
r = requests.get(authority_url,
headers={"Accept": "application/x-x509-ca-cert,application/x-pem-file"})
cert = crypto.load_certificate(crypto.FILETYPE_PEM, r.text)
except crypto.Error:
raise ValueError("Failed to parse PEM: %s" % r.text)
authority_partial = tempfile.mktemp(prefix=authority_path + ".part")
with open(authority_partial, "w") as oh:
oh.write(r.text)
click.echo("Writing authority certificate to: %s" % authority_path)
os.rename(authority_partial, authority_path)
# Fetch certificate revocation list
r = requests.get(revoked_url, headers={'accept': 'application/x-pem-file'}, stream=True)
click.echo("Fetching CRL from %s to %s" % (revoked_url, revocations_path))
revocations_partial = tempfile.mktemp(prefix=revocations_path + ".part")
with open(revocations_partial, 'wb') as f:
for chunk in r.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
if subprocess.call(("openssl", "crl", "-CAfile", authority_path, "-in", revocations_partial, "-noout")):
raise ValueError("Failed to verify CRL in %s" % revocations_partial)
else:
# TODO: Check monotonically increasing CRL number
click.echo("Certificate revocation list passed verification")
os.rename(revocations_partial, revocations_path)
# Check if we have been inserted into CRL
if os.path.exists(certificate_path):
cert = crypto.load_certificate(crypto.FILETYPE_PEM, open(certificate_path).read())
revocation_list = crypto.load_crl(crypto.FILETYPE_PEM, open(revocations_path).read())
for revocation in revocation_list.get_revoked():
if int(revocation.get_serial(), 16) == cert.get_serial_number():
if revocation.get_reason() == "Certificate Hold": # TODO: 'Remove From CRL'
# TODO: Disable service for time being
click.echo("Certificate put on hold, doing nothing for now")
break
# Disable the client if operation has been ceased or
# the certificate has been superseded by other
if revocation.get_reason() in ("Cessation Of Operation", "Superseded"):
if os.path.exists("/etc/certidude/client.conf"):
clients.readfp(open("/etc/certidude/client.conf"))
if clients.has_section(server):
clients.set(server, "trigger", "operation ceased")
clients.write(open("/etc/certidude/client.conf", "w"))
click.echo("Authority operation ceased, disabling in /etc/certidude/client.conf")
# TODO: Disable related services
if revocation.get_reason() in ("CA Compromise", "AA Compromise"):
if os.path.exists(authority_path):
os.remove(key_path)
click.echo("Certificate has been revoked, wiping keys and certificates!")
if os.path.exists(key_path):
os.remove(key_path)
if os.path.exists(request_path):
os.remove(request_path)
if os.path.exists(certificate_path):
os.remove(certificate_path)
break
else:
click.echo("Certificate does not seem to be revoked. Good!")
try:
request = Request(open(request_path))
click.echo("Found signing request: %s" % request_path)
except EnvironmentError:
# Construct private key
click.echo("Generating 4096-bit RSA key...")
key = rsa.generate_private_key(
public_exponent=65537,
key_size=4096,
backend=default_backend()
)
# Dump private key
key_partial = tempfile.mktemp(prefix=key_path + ".part")
os.umask(0o077)
with open(key_partial, "wb") as fh:
fh.write(key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
))
# Set subject name attributes
names = [x509.NameAttribute(NameOID.COMMON_NAME, common_name.decode("utf-8"))]
if given_name:
names.append(x509.NameAttribute(NameOID.GIVEN_NAME, given_name.decode("utf-8")))
if surname:
names.append(x509.NameAttribute(NameOID.SURNAME, surname.decode("utf-8")))
if org_unit:
names.append(x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT, org_unit.decode("utf-8")))
# Collect subject alternative names
subject_alt_names = set()
if email_address:
subject_alt_names.add(x509.RFC822Name(email_address))
if ip_address:
subject_alt_names.add("IP:%s" % ip_address)
if dns:
subject_alt_names.add(x509.DNSName(dns))
# Construct CSR
csr = x509.CertificateSigningRequestBuilder(
).subject_name(x509.Name(names))
if extended_key_usage_flags:
click.echo("Adding extended key usage extension: %s" % extended_key_usage_flags)
csr = csr.add_extension(x509.ExtendedKeyUsage(
extended_key_usage_flags), critical=True)
if subject_alt_names:
click.echo("Adding subject alternative name extension: %s" % subject_alt_names)
csr = csr.add_extension(
x509.SubjectAlternativeName(subject_alt_names),
critical=False)
# Sign & dump CSR
os.umask(0o022)
with open(request_path + ".part", "wb") as f:
f.write(csr.sign(key, hashes.SHA256(), default_backend()).public_bytes(serialization.Encoding.PEM))
click.echo("Writing private key to: %s" % key_path)
os.rename(key_partial, key_path)
click.echo("Writing certificate signing request to: %s" % request_path)
os.rename(request_path + ".part", request_path)
# We have CSR now, save the paths to client.conf so we could:
# Update CRL, renew certificate, maybe something extra?
if os.path.exists(certificate_path):
click.echo("Found certificate: %s" % certificate_path)
# TODO: Check certificate validity, download CRL?
return
# If machine is joined to domain attempt to present machine credentials for authentication
if os.path.exists("/etc/krb5.keytab") and os.path.exists("/etc/samba/smb.conf"):
# Get HTTP service ticket
from configparser import ConfigParser
cp = ConfigParser(delimiters=("="))
cp.readfp(open("/etc/samba/smb.conf"))
name = cp.get("global", "netbios name")
realm = cp.get("global", "realm")
os.environ["KRB5CCNAME"]="/tmp/ca.ticket"
os.system("kinit -k %s$ -S HTTP/%s@%s -t /etc/krb5.keytab" % (name, server, realm))
from requests_kerberos import HTTPKerberosAuth, OPTIONAL
auth = HTTPKerberosAuth(mutual_authentication=OPTIONAL, force_preemptive=True)
else:
auth = None
click.echo("Submitting to %s, waiting for response..." % request_url)
submission = requests.post(request_url,
auth=auth,
data=open(request_path),
headers={"Content-Type": "application/pkcs10", "Accept": "application/x-x509-user-cert,application/x-pem-file"})
# Destroy service ticket
if os.path.exists("/tmp/ca.ticket"):
os.system("kdestroy")
if submission.status_code == requests.codes.ok:
pass
if submission.status_code == requests.codes.accepted:
# Server stored the request for processing (202 Accepted), but waiting was not requested, hence quitting for now
return
if submission.status_code == requests.codes.conflict:
raise errors.DuplicateCommonNameError("Different signing request with same CN is already present on server, server refuses to overwrite")
elif submission.status_code == requests.codes.gone:
# Should the client retry or disable request submission?
raise ValueError("Server refused to sign the request") # TODO: Raise proper exception
else:
submission.raise_for_status()
try:
cert = crypto.load_certificate(crypto.FILETYPE_PEM, submission.text)
except crypto.Error:
raise ValueError("Failed to parse PEM: %s" % submission.text)
os.umask(0o022)
with open(certificate_path + ".part", "w") as fh:
# Dump certificate
fh.write(submission.text)
# Bundle CA certificate, necessary for nginx
if bundle:
with open(authority_path) as ch:
fh.write(ch.read())
click.echo("Writing certificate to: %s" % certificate_path)
os.rename(certificate_path + ".part", certificate_path)
# TODO: Validate fetched certificate against CA
# TODO: Check that recevied certificate CN and pubkey match
# TODO: Check file permissions