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:
parent
1493c0f4a0
commit
b9aaec7fa6
@ -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,
|
||||
|
@ -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)
|
||||
|
126
certidude/cli.py
126
certidude/cli.py
@ -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"):
|
||||
|
@ -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")
|
||||
|
||||
|
@ -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"
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user