Migrate renewal to mutually authenticated TLS connection

This commit is contained in:
Lauri Võsandi 2018-04-15 19:27:22 +00:00
parent 1493c0f4a0
commit b9aaec7fa6
7 changed files with 111 additions and 101 deletions

View File

@ -4,7 +4,7 @@ import logging
import json
import os
import hashlib
from asn1crypto import pem
from asn1crypto import pem, x509
from asn1crypto.csr import CertificationRequest
from base64 import b64decode
from certidude import config, push, errors
@ -89,43 +89,29 @@ class RequestListResource(AuthorityHandler):
cert_pk = cert["tbs_certificate"]["subject_public_key_info"].native
csr_pk = csr["certification_request_info"]["subject_pk_info"].native
if cert_pk == csr_pk: # Same public key, assume renewal
expires = cert["tbs_certificate"]["validity"]["not_after"].native.replace(tzinfo=None)
renewal_header = req.get_header("X-Renewal-Signature")
try:
buf = req.get_header("X-SSL-CERT")
header, _, der_bytes = pem.unarmor(buf.replace("\t", "").encode("ascii"))
handshake_cert = x509.Certificate.load(der_bytes)
except:
raise
else:
# Same public key
if cert_pk == csr_pk:
# Used mutually authenticated TLS handshake, assume renewal
if handshake_cert.native == cert.native:
for subnet in config.RENEWAL_SUBNETS:
if req.context.get("remote_addr") in subnet:
resp.set_header("Content-Type", "application/x-x509-user-cert")
_, resp.body = self.authority._sign(csr, body, overwrite=True)
logger.info("Renewing certificate for %s as %s is whitelisted", common_name, req.context.get("remote_addr"))
return
if not renewal_header:
# No header supplied, redirect to signed API call
resp.status = falcon.HTTP_SEE_OTHER
resp.location = os.path.join(os.path.dirname(req.relative_uri), "signed", common_name)
return
try:
renewal_signature = b64decode(renewal_header)
except (TypeError, ValueError):
logger.error("Renewal failed, bad signature supplied for %s", common_name)
reasons.append("Renewal failed, bad signature supplied")
else:
try:
asymmetric.rsa_pss_verify(
asymmetric.load_certificate(cert),
renewal_signature, buf + body, "sha512")
except SignatureError:
logger.error("Renewal failed, invalid signature supplied for %s", common_name)
reasons.append("Renewal failed, invalid signature supplied")
else:
# At this point renewal signature was valid but we need to perform some extra checks
if datetime.utcnow() > expires:
logger.error("Renewal failed, current certificate for %s has expired", common_name)
reasons.append("Renewal failed, current certificate expired")
elif not config.CERTIFICATE_RENEWAL_ALLOWED:
logger.error("Renewal requested for %s, but not allowed by authority settings", common_name)
reasons.append("Renewal requested, but not allowed by authority settings")
else:
resp.set_header("Content-Type", "application/x-x509-user-cert")
_, resp.body = self.authority._sign(csr, body, overwrite=True)
logger.info("Renewed certificate for %s", common_name)
return
"""
Process automatic signing if the IP address is whitelisted,

View File

@ -21,8 +21,6 @@ from xattr import getxattr, listxattr, setxattr
random = SystemRandom()
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
@ -84,7 +82,7 @@ def self_enroll():
def get_request(common_name):
if not re.match(RE_HOSTNAME, common_name):
if not re.match(const.RE_HOSTNAME, common_name):
raise ValueError("Invalid common name %s" % repr(common_name))
path = os.path.join(config.REQUESTS_DIR, common_name + ".pem")
try:
@ -97,7 +95,7 @@ def get_request(common_name):
raise errors.RequestDoesNotExist("Certificate signing request file %s does not exist" % path)
def get_signed(common_name):
if not re.match(RE_HOSTNAME, common_name):
if not re.match(const.RE_HOSTNAME, common_name):
raise ValueError("Invalid common name %s" % repr(common_name))
path = os.path.join(config.SIGNED_DIR, common_name + ".pem")
with open(path, "rb") as fh:
@ -160,7 +158,7 @@ def store_request(buf, overwrite=False, address="", user=""):
common_name = csr["certification_request_info"]["subject"].native["common_name"]
if not re.match(RE_HOSTNAME, common_name):
if not re.match(const.RE_HOSTNAME, common_name):
raise ValueError("Invalid common name")
request_path = os.path.join(config.REQUESTS_DIR, common_name + ".pem")
@ -298,7 +296,7 @@ def export_crl(pem=True):
def delete_request(common_name):
# Validate CN
if not re.match(RE_HOSTNAME, common_name):
if not re.match(const.RE_HOSTNAME, common_name):
raise ValueError("Invalid common name")
path, buf, csr, submitted = get_request(common_name)

View File

@ -5,11 +5,13 @@ import hashlib
import logging
import os
import random
import re
import signal
import string
import subprocess
import sys
from asn1crypto import pem, x509
from asn1crypto.csr import CertificationRequest
from base64 import b64encode
from certbuilder import CertificateBuilder, pem_armor_certificate
from certidude import const
@ -177,17 +179,6 @@ def certidude_enroll(fork, renew, no_wait, kerberos, skip_self):
with open(pid_path, "w") as fh:
fh.write("%d\n" % os.getpid())
try:
scheme = "http" if clients.getboolean(authority_name, "insecure") else "https"
except NoOptionError:
scheme = "https"
# Expand ca.example.com
authority_url = "%s://%s/api/certificate/" % (scheme, authority_name)
request_url = "%s://%s/api/request/" % (scheme, authority_name)
revoked_url = "%s://%s/api/revoked/" % (scheme, authority_name)
try:
authority_path = clients.get(authority_name, "authority path")
except NoOptionError:
@ -201,6 +192,7 @@ def certidude_enroll(fork, renew, no_wait, kerberos, skip_self):
else:
if not os.path.exists(os.path.dirname(authority_path)):
os.makedirs(os.path.dirname(authority_path))
authority_url = "http://%s/api/certificate/" % authority_name
click.echo("Attempting to fetch authority certificate from %s" % authority_url)
try:
r = requests.get(authority_url,
@ -260,15 +252,21 @@ def certidude_enroll(fork, renew, no_wait, kerberos, skip_self):
revocations_path = None
else:
# Fetch certificate revocation list
revoked_url = "http://%s/api/revoked/" % authority_name
click.echo("Fetching CRL from %s to %s" % (revoked_url, revocations_path))
r = requests.get(revoked_url, headers={'accept': 'application/x-pem-file'})
assert r.status_code == 200, "Failed to fetch CRL from %s, got %s" % (revoked_url, r.text)
#revocations = crl.CertificateList.load(pem.unarmor(r.content))
# TODO: check signature, parse reasons, remove keys if revoked
revocations_partial = revocations_path + ".part"
with open(revocations_partial, 'wb') as f:
f.write(r.content)
if r.status_code == 200:
revocations = crl.CertificateList.load(pem.unarmor(r.content))
# TODO: check signature, parse reasons, remove keys if revoked
revocations_partial = revocations_path + ".part"
with open(revocations_partial, 'wb') as f:
f.write(r.content)
elif r.status_code == 404:
click.echo("CRL disabled, server said 404")
else:
click.echo("Failed to fetch CRL from %s, got %s" % (revoked_url, r.text))
try:
common_name = clients.get(authority_name, "common name")
@ -279,6 +277,13 @@ def certidude_enroll(fork, renew, no_wait, kerberos, skip_self):
# If deriving common name from *current* hostname is preferred
if common_name == "$HOSTNAME":
common_name = const.HOSTNAME
elif common_name == "$FQDN":
common_name = const.FQDN
elif "$" in common_name:
raise ValueError("Invalid variable '%s' supplied, only $HOSTNAME and $FQDN allowed" % common_name)
if not re.match(const.RE_HOSTNAME, common_name):
raise ValueError("Invalid common name '%s' supplied" % common_name)
################################
### Generate keypair and CSR ###
@ -291,6 +296,14 @@ def certidude_enroll(fork, renew, no_wait, kerberos, skip_self):
key_path = "/var/lib/certidude/%s/client_key.pem" % authority_name
request_path = "/var/lib/certidude/%s/client_csr.pem" % authority_name
if os.path.exists(request_path):
with open(request_path, "rb") as fh:
header, _, der_bytes = pem.unarmor(fh.read())
csr = CertificationRequest.load(der_bytes)
if csr["certification_request_info"]["subject"].native["common_name"] != common_name:
click.echo("Stored request's common name differs from currently requested one, deleting old request")
os.remove(request_path)
if not os.path.exists(request_path):
key_partial = key_path + ".part"
request_partial = request_path + ".part"
@ -312,7 +325,7 @@ def certidude_enroll(fork, renew, no_wait, kerberos, skip_self):
selinux_fixup(request_partial)
os.rename(key_partial, key_path)
os.rename(request_partial, request_path)
# else: check that CSR has correct CN
##############################################
### Submit CSR and save signed certificate ###
@ -323,14 +336,7 @@ def certidude_enroll(fork, renew, no_wait, kerberos, skip_self):
except NoOptionError:
certificate_path = "/var/lib/certidude/%s/client_cert.pem" % authority_name
headers={
"Content-Type": "application/pkcs10",
"Accept": "application/x-x509-user-cert,application/x-pem-file"
}
try:
# Attach renewal signature if renewal requested and cert exists
renewal_overlap = clients.getint(authority_name, "renewal overlap")
except NoOptionError: # Renewal not specified in config
renewal_overlap = None
@ -343,15 +349,8 @@ def certidude_enroll(fork, renew, no_wait, kerberos, skip_self):
if renewal_overlap and NOW > expires - timedelta(days=renewal_overlap):
click.echo("Certificate will expire %s, will attempt to renew" % expires)
renew = True
headers["X-Renewal-Signature"] = b64encode(
asymmetric.rsa_pss_sign(
asymmetric.load_private_key(kh.read()),
cert_buf + rh.read(),
"sha384"))
except EnvironmentError: # Certificate missing, can't renew
pass
else:
click.echo("Attached renewal signature %s" % headers["X-Renewal-Signature"])
if not os.path.exists(certificate_path) or renew:
# Set up URL-s
@ -359,31 +358,48 @@ def certidude_enroll(fork, renew, no_wait, kerberos, skip_self):
request_params.add("autosign=true")
if not no_wait:
request_params.add("wait=forever")
kwargs = {
"data": open(request_path),
"verify": authority_path,
"headers": {
"Content-Type": "application/pkcs10",
"Accept": "application/x-x509-user-cert,application/x-pem-file"
}
}
if renew: # Do mutually authenticated TLS handshake
request_url = "https://%s:8443/api/request/" % authority_name
kwargs["cert"] = certificate_path, key_path
else:
# If machine is joined to domain attempt to present machine credentials for authentication
if kerberos:
try:
from requests_kerberos import HTTPKerberosAuth, OPTIONAL
except ImportError:
click.echo("Kerberos bindings not available, please install requests-kerberos")
else:
os.environ["KRB5CCNAME"]="/tmp/ca.ticket"
# Mac OS X has keytab with lowercase hostname
cmd = "kinit -S HTTP/%s -k %s$" % (authority_name, const.HOSTNAME.lower())
click.echo("Executing: %s" % cmd)
if os.system(cmd):
# Fedora /w SSSD has keytab with uppercase hostname
cmd = "kinit -S HTTP/%s -k %s$" % (authority_name, const.HOSTNAME.upper())
if os.system(cmd):
# Failed, probably /etc/krb5.keytab contains spaghetti
raise ValueError("Failed to initialize Kerberos service ticket using machine keytab")
assert os.path.exists("/tmp/ca.ticket"), "Ticket not created!"
click.echo("Initialized Kerberos service ticket using machine keytab")
kwargs["auth"] = HTTPKerberosAuth(mutual_authentication=OPTIONAL, force_preemptive=True)
else:
click.echo("Not using machine keytab")
request_url = "https://%s/api/request/" % authority_name
if request_params:
request_url = request_url + "?" + "&".join(request_params)
# If machine is joined to domain attempt to present machine credentials for authentication
if kerberos:
os.environ["KRB5CCNAME"]="/tmp/ca.ticket"
# Mac OS X has keytab with lowercase hostname
cmd = "kinit -S HTTP/%s -k %s$" % (authority_name, const.HOSTNAME.lower())
click.echo("Executing: %s" % cmd)
if os.system(cmd):
# Fedora /w SSSD has keytab with uppercase hostname
cmd = "kinit -S HTTP/%s -k %s$" % (authority_name, const.HOSTNAME.upper())
if os.system(cmd):
# Failed, probably /etc/krb5.keytab contains spaghetti
raise ValueError("Failed to initialize TGT using machine keytab")
assert os.path.exists("/tmp/ca.ticket"), "Ticket not created!"
click.echo("Initialized Kerberos TGT using machine keytab")
from requests_kerberos import HTTPKerberosAuth, OPTIONAL
auth = HTTPKerberosAuth(mutual_authentication=OPTIONAL, force_preemptive=True)
else:
click.echo("Not using machine keytab")
auth = None
submission = requests.post(request_url, auth=auth, data=open(request_path), headers=headers)
submission = requests.post(request_url, **kwargs)
# Destroy service ticket
if os.path.exists("/tmp/ca.ticket"):

View File

@ -35,6 +35,8 @@ OCSP_SUBNETS = set([ipaddress.ip_network(j) for j in
cp.get("authorization", "ocsp subnets").split(" ") if j])
CRL_SUBNETS = set([ipaddress.ip_network(j) for j in
cp.get("authorization", "crl subnets").split(" ") if j])
RENEWAL_SUBNETS = set([ipaddress.ip_network(j) for j in
cp.get("authorization", "renewal subnets").split(" ") if j])
AUTHORITY_DIR = "/var/lib/certidude"
AUTHORITY_PRIVATE_KEY_PATH = cp.get("authority", "private key path")
@ -64,7 +66,6 @@ REQUEST_SUBMISSION_ALLOWED = cp.getboolean("authority", "request submission allo
AUTHORITY_CERTIFICATE_URL = cp.get("signature", "authority certificate url")
AUTHORITY_CRL_URL = cp.get("signature", "revoked url")
AUTHORITY_OCSP_URL = cp.get("signature", "responder url")
CERTIFICATE_RENEWAL_ALLOWED = cp.getboolean("signature", "renewal allowed")
REVOCATION_LIST_LIFETIME = cp.getint("signature", "revocation list lifetime")

View File

@ -6,6 +6,7 @@ import sys
KEY_SIZE = 1024 if os.getenv("TRAVIS") else 4096
CURVE_NAME = "secp384r1"
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]))?$"
RUN_DIR = "/run/certidude"
CONFIG_DIR = "/etc/certidude"

View File

@ -119,14 +119,21 @@ server {
nchan_channel_id $1;
nchan_subscriber eventsource;
}
# Long poll for CSR submission
location ~ "^/lp/sub/(.*)" {
nchan_channel_id $1;
nchan_subscriber longpoll;
}
{% endif %}
}
server {
# Section for certificate authenticated HTTPS clients,
# for submitting information to CA eg. leases
# and for delivering scripts to clients
# for submitting information to CA eg. leases,
# renewing certificates and
# for delivering scripts to clients
server_name {{ common_name }};
listen 8443 ssl http2;

View File

@ -70,6 +70,12 @@ ocsp subnets =
;crl subnets =
crl subnets = 0.0.0.0/0
# If certificate renewal is attempted from whitelisted subnets, clients can
# request a certificate for the same public key with extended lifetime.
# To disable set to none
renewal subnets =
;renewal subnets = 0.0.0.0/0
[logging]
# Disable logging
;backend =
@ -107,11 +113,6 @@ revoked url = {{ revoked_url }}
responder url =
;responder url = {{ responder_url }}
# If certificate renewal is allowed clients can request a certificate
# for the same public key with extended lifetime
renewal allowed = false
;renewal allowed = true
[push]
# This should occasionally be regenerated