1
0
mirror of https://github.com/laurivosandi/certidude synced 2024-12-22 16:25:17 +00:00

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 json
import os import os
import hashlib import hashlib
from asn1crypto import pem from asn1crypto import pem, x509
from asn1crypto.csr import CertificationRequest from asn1crypto.csr import CertificationRequest
from base64 import b64decode from base64 import b64decode
from certidude import config, push, errors from certidude import config, push, errors
@ -89,43 +89,29 @@ class RequestListResource(AuthorityHandler):
cert_pk = cert["tbs_certificate"]["subject_public_key_info"].native cert_pk = cert["tbs_certificate"]["subject_public_key_info"].native
csr_pk = csr["certification_request_info"]["subject_pk_info"].native csr_pk = csr["certification_request_info"]["subject_pk_info"].native
if cert_pk == csr_pk: # Same public key, assume renewal try:
expires = cert["tbs_certificate"]["validity"]["not_after"].native.replace(tzinfo=None) buf = req.get_header("X-SSL-CERT")
renewal_header = req.get_header("X-Renewal-Signature") 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 # No header supplied, redirect to signed API call
resp.status = falcon.HTTP_SEE_OTHER resp.status = falcon.HTTP_SEE_OTHER
resp.location = os.path.join(os.path.dirname(req.relative_uri), "signed", common_name) resp.location = os.path.join(os.path.dirname(req.relative_uri), "signed", common_name)
return 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, Process automatic signing if the IP address is whitelisted,

View File

@ -21,8 +21,6 @@ from xattr import getxattr, listxattr, setxattr
random = SystemRandom() 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://securityblog.redhat.com/2014/06/18/openssl-privilege-separation-analysis/
# https://jamielinux.com/docs/openssl-certificate-authority/ # https://jamielinux.com/docs/openssl-certificate-authority/
# http://pycopia.googlecode.com/svn/trunk/net/pycopia/ssl/certs.py # http://pycopia.googlecode.com/svn/trunk/net/pycopia/ssl/certs.py
@ -84,7 +82,7 @@ def self_enroll():
def get_request(common_name): 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)) raise ValueError("Invalid common name %s" % repr(common_name))
path = os.path.join(config.REQUESTS_DIR, common_name + ".pem") path = os.path.join(config.REQUESTS_DIR, common_name + ".pem")
try: try:
@ -97,7 +95,7 @@ def get_request(common_name):
raise errors.RequestDoesNotExist("Certificate signing request file %s does not exist" % path) raise errors.RequestDoesNotExist("Certificate signing request file %s does not exist" % path)
def get_signed(common_name): 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)) raise ValueError("Invalid common name %s" % repr(common_name))
path = os.path.join(config.SIGNED_DIR, common_name + ".pem") path = os.path.join(config.SIGNED_DIR, common_name + ".pem")
with open(path, "rb") as fh: 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"] 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") raise ValueError("Invalid common name")
request_path = os.path.join(config.REQUESTS_DIR, common_name + ".pem") 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): def delete_request(common_name):
# Validate CN # Validate CN
if not re.match(RE_HOSTNAME, common_name): if not re.match(const.RE_HOSTNAME, common_name):
raise ValueError("Invalid common name") raise ValueError("Invalid common name")
path, buf, csr, submitted = get_request(common_name) path, buf, csr, submitted = get_request(common_name)

View File

@ -5,11 +5,13 @@ import hashlib
import logging import logging
import os import os
import random import random
import re
import signal import signal
import string import string
import subprocess import subprocess
import sys import sys
from asn1crypto import pem, x509 from asn1crypto import pem, x509
from asn1crypto.csr import CertificationRequest
from base64 import b64encode from base64 import b64encode
from certbuilder import CertificateBuilder, pem_armor_certificate from certbuilder import CertificateBuilder, pem_armor_certificate
from certidude import const 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: with open(pid_path, "w") as fh:
fh.write("%d\n" % os.getpid()) 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: try:
authority_path = clients.get(authority_name, "authority path") authority_path = clients.get(authority_name, "authority path")
except NoOptionError: except NoOptionError:
@ -201,6 +192,7 @@ def certidude_enroll(fork, renew, no_wait, kerberos, skip_self):
else: else:
if not os.path.exists(os.path.dirname(authority_path)): if not os.path.exists(os.path.dirname(authority_path)):
os.makedirs(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) click.echo("Attempting to fetch authority certificate from %s" % authority_url)
try: try:
r = requests.get(authority_url, r = requests.get(authority_url,
@ -260,15 +252,21 @@ def certidude_enroll(fork, renew, no_wait, kerberos, skip_self):
revocations_path = None revocations_path = None
else: else:
# Fetch certificate revocation list # Fetch certificate revocation list
revoked_url = "http://%s/api/revoked/" % authority_name
click.echo("Fetching CRL from %s to %s" % (revoked_url, revocations_path)) click.echo("Fetching CRL from %s to %s" % (revoked_url, revocations_path))
r = requests.get(revoked_url, headers={'accept': 'application/x-pem-file'}) 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)) if r.status_code == 200:
# TODO: check signature, parse reasons, remove keys if revoked revocations = crl.CertificateList.load(pem.unarmor(r.content))
revocations_partial = revocations_path + ".part" # TODO: check signature, parse reasons, remove keys if revoked
with open(revocations_partial, 'wb') as f: revocations_partial = revocations_path + ".part"
f.write(r.content) 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: try:
common_name = clients.get(authority_name, "common name") 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 deriving common name from *current* hostname is preferred
if common_name == "$HOSTNAME": if common_name == "$HOSTNAME":
common_name = const.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 ### ### 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 key_path = "/var/lib/certidude/%s/client_key.pem" % authority_name
request_path = "/var/lib/certidude/%s/client_csr.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): if not os.path.exists(request_path):
key_partial = key_path + ".part" key_partial = key_path + ".part"
request_partial = request_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) selinux_fixup(request_partial)
os.rename(key_partial, key_path) os.rename(key_partial, key_path)
os.rename(request_partial, request_path) os.rename(request_partial, request_path)
# else: check that CSR has correct CN
############################################## ##############################################
### Submit CSR and save signed certificate ### ### Submit CSR and save signed certificate ###
@ -323,14 +336,7 @@ def certidude_enroll(fork, renew, no_wait, kerberos, skip_self):
except NoOptionError: except NoOptionError:
certificate_path = "/var/lib/certidude/%s/client_cert.pem" % authority_name 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: try:
# Attach renewal signature if renewal requested and cert exists
renewal_overlap = clients.getint(authority_name, "renewal overlap") renewal_overlap = clients.getint(authority_name, "renewal overlap")
except NoOptionError: # Renewal not specified in config except NoOptionError: # Renewal not specified in config
renewal_overlap = None 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): if renewal_overlap and NOW > expires - timedelta(days=renewal_overlap):
click.echo("Certificate will expire %s, will attempt to renew" % expires) click.echo("Certificate will expire %s, will attempt to renew" % expires)
renew = True 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 except EnvironmentError: # Certificate missing, can't renew
pass pass
else:
click.echo("Attached renewal signature %s" % headers["X-Renewal-Signature"])
if not os.path.exists(certificate_path) or renew: if not os.path.exists(certificate_path) or renew:
# Set up URL-s # Set up URL-s
@ -359,31 +358,48 @@ def certidude_enroll(fork, renew, no_wait, kerberos, skip_self):
request_params.add("autosign=true") request_params.add("autosign=true")
if not no_wait: if not no_wait:
request_params.add("wait=forever") 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: if request_params:
request_url = request_url + "?" + "&".join(request_params) request_url = request_url + "?" + "&".join(request_params)
submission = requests.post(request_url, **kwargs)
# 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)
# Destroy service ticket # Destroy service ticket
if os.path.exists("/tmp/ca.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]) cp.get("authorization", "ocsp subnets").split(" ") if j])
CRL_SUBNETS = set([ipaddress.ip_network(j) for j in CRL_SUBNETS = set([ipaddress.ip_network(j) for j in
cp.get("authorization", "crl subnets").split(" ") if j]) 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_DIR = "/var/lib/certidude"
AUTHORITY_PRIVATE_KEY_PATH = cp.get("authority", "private key path") 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_CERTIFICATE_URL = cp.get("signature", "authority certificate url")
AUTHORITY_CRL_URL = cp.get("signature", "revoked url") AUTHORITY_CRL_URL = cp.get("signature", "revoked url")
AUTHORITY_OCSP_URL = cp.get("signature", "responder 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") 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 KEY_SIZE = 1024 if os.getenv("TRAVIS") else 4096
CURVE_NAME = "secp384r1" 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" RUN_DIR = "/run/certidude"
CONFIG_DIR = "/etc/certidude" CONFIG_DIR = "/etc/certidude"

View File

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

View File

@ -70,6 +70,12 @@ ocsp subnets =
;crl subnets = ;crl subnets =
crl subnets = 0.0.0.0/0 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] [logging]
# Disable logging # Disable logging
;backend = ;backend =
@ -107,11 +113,6 @@ revoked url = {{ revoked_url }}
responder url = responder url =
;responder 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] [push]
# This should occasionally be regenerated # This should occasionally be regenerated