Migrate signature profiles to separate config file

This commit is contained in:
Lauri Võsandi 2018-04-16 12:13:31 +00:00
parent b9aaec7fa6
commit 94e5f72566
10 changed files with 179 additions and 64 deletions

View File

@ -163,7 +163,8 @@ class SessionResource(AuthorityHandler):
admin_subnets=config.ADMIN_SUBNETS or None,
signature = dict(
revocation_list_lifetime=config.REVOCATION_LIST_LIFETIME,
profiles = [dict(name=k, server=v[0]=="server", lifetime=v[1], organizational_unit=v[2], title=v[3]) for k,v in config.PROFILES.items()]
profiles = sorted([p.serialize() for p in config.PROFILES.values()], key=lambda p:p.get("slug")),
)
) if req.context.get("user").is_admin() else None,
features=dict(

View File

@ -10,6 +10,7 @@ from base64 import b64decode
from certidude import config, push, errors
from certidude.auth import login_required, login_optional, authorize_admin
from certidude.decorators import csrf_protection, MyEncoder
from certidude.profile import SignatureProfile
from datetime import datetime
from oscrypto import asymmetric
from oscrypto.errors import SignatureError
@ -71,7 +72,8 @@ class RequestListResource(AuthorityHandler):
# Automatic enroll with Kerberos machine cerdentials
resp.set_header("Content-Type", "application/x-pem-file")
cert, resp.body = self.authority._sign(csr, body, overwrite=True)
cert, resp.body = self.authority._sign(csr, body,
profile=config.PROFILES["rw"], overwrite=True)
logger.info("Automatically enrolled Kerberos authenticated machine %s from %s",
machine, req.context.get("remote_addr"))
return
@ -89,28 +91,26 @@ class RequestListResource(AuthorityHandler):
cert_pk = cert["tbs_certificate"]["subject_public_key_info"].native
csr_pk = csr["certification_request_info"]["subject_pk_info"].native
try:
# Same public key
if cert_pk == csr_pk:
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
# Used mutually authenticated TLS handshake, assume renewal
if buf:
header, _, der_bytes = pem.unarmor(buf.replace("\t", "").encode("ascii"))
handshake_cert = x509.Certificate.load(der_bytes)
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)
_, resp.body = self.authority._sign(csr, body, overwrite=True,
profile=SignatureProfile.from_cert(cert))
logger.info("Renewing certificate for %s as %s is whitelisted", common_name, req.context.get("remote_addr"))
return
# 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
# 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
"""
@ -123,7 +123,7 @@ class RequestListResource(AuthorityHandler):
if req.context.get("remote_addr") in subnet:
try:
resp.set_header("Content-Type", "application/x-pem-file")
_, resp.body = self.authority._sign(csr, body)
_, resp.body = self.authority._sign(csr, body, profile=config.PROFILES["rw"])
logger.info("Autosigned %s as %s is whitelisted", common_name, req.context.get("remote_addr"))
return
except EnvironmentError:
@ -222,7 +222,7 @@ class RequestDetailResource(AuthorityHandler):
"""
try:
cert, buf = self.authority.sign(cn,
profile=req.get_param("profile", default="default"),
profile=config.PROFILES[req.get_param("profile", default="rw")],
overwrite=True,
signer=req.context.get("user").name)
# Mailing and long poll publishing implemented in the function above

View File

@ -71,7 +71,7 @@ def self_enroll():
from certidude import authority
from certidude.common import drop_privileges
drop_privileges()
authority.sign(common_name, skip_push=True, overwrite=True, profile="srv")
authority.sign(common_name, skip_push=True, overwrite=True, profile=config.PROFILES["srv"])
sys.exit(0)
else:
os.waitpid(pid, 0)
@ -82,7 +82,7 @@ def self_enroll():
def get_request(common_name):
if not re.match(const.RE_HOSTNAME, common_name):
if not re.match(const.RE_COMMON_NAME, common_name):
raise ValueError("Invalid common name %s" % repr(common_name))
path = os.path.join(config.REQUESTS_DIR, common_name + ".pem")
try:
@ -95,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(const.RE_HOSTNAME, common_name):
if not re.match(const.RE_COMMON_NAME, 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:
@ -158,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(const.RE_HOSTNAME, common_name):
if not re.match(const.RE_COMMON_NAME, common_name):
raise ValueError("Invalid common name")
request_path = os.path.join(config.REQUESTS_DIR, common_name + ".pem")
@ -296,7 +296,7 @@ def export_crl(pem=True):
def delete_request(common_name):
# Validate CN
if not re.match(const.RE_HOSTNAME, common_name):
if not re.match(const.RE_COMMON_NAME, common_name):
raise ValueError("Invalid common name")
path, buf, csr, submitted = get_request(common_name)
@ -310,7 +310,7 @@ def delete_request(common_name):
config.LONG_POLL_PUBLISH % hashlib.sha256(buf).hexdigest(),
headers={"User-Agent": "Certidude API"})
def sign(common_name, skip_notify=False, skip_push=False, overwrite=False, profile="default", signer=None):
def sign(common_name, profile, skip_notify=False, skip_push=False, overwrite=False, signer=None):
"""
Sign certificate signing request by it's common name
"""
@ -323,16 +323,13 @@ def sign(common_name, skip_notify=False, skip_push=False, overwrite=False, profi
# Sign with function below
cert, buf = _sign(csr, csr_buf, skip_notify, skip_push, overwrite, profile, signer)
cert, buf = _sign(csr, csr_buf, profile, skip_notify, skip_push, overwrite, signer)
os.unlink(req_path)
return cert, buf
def _sign(csr, buf, skip_notify=False, skip_push=False, overwrite=False, profile="default", signer=None):
def _sign(csr, buf, profile, skip_notify=False, skip_push=False, overwrite=False, signer=None):
# TODO: CRLDistributionPoints, OCSP URL, Certificate URL
if profile not in config.PROFILES:
raise ValueError("Invalid profile supplied '%s'" % profile)
assert buf.startswith(b"-----BEGIN ")
assert isinstance(csr, CertificationRequest)
csr_pubkey = asymmetric.load_public_key(csr["certification_request_info"]["subject_pk_info"])
@ -370,10 +367,9 @@ def _sign(csr, buf, skip_notify=False, skip_push=False, overwrite=False, profile
else:
raise FileExistsError("Will not overwrite existing certificate")
# Sign via signer process
dn = {u'common_name': common_name }
profile_server_flags, lifetime, dn["organizational_unit_name"], _ = config.PROFILES[profile]
lifetime = int(lifetime)
if profile.ou:
dn["organizational_unit_name"] = profile.ou
builder = CertificateBuilder(dn, csr_pubkey)
builder.serial_number = random.randint(
@ -382,18 +378,12 @@ def _sign(csr, buf, skip_notify=False, skip_push=False, overwrite=False, profile
now = datetime.utcnow()
builder.begin_date = now - timedelta(minutes=5)
builder.end_date = now + timedelta(days=lifetime)
builder.end_date = now + timedelta(days=profile.lifetime)
builder.issuer = certificate
builder.ca = False
builder.key_usage = set(["digital_signature", "key_encipherment"])
# If we have FQDN and profile suggests server flags, enable them
if server_flags(common_name) and profile_server_flags:
builder.subject_alt_domains = [common_name] # OpenVPN uses CN while StrongSwan uses SAN to match hostname of the server
builder.extended_key_usage = set(["server_auth", "1.3.6.1.5.5.8.2.2", "client_auth"])
else:
builder.subject_alt_domains = [common_name] # iOS demands SAN also for clients
builder.extended_key_usage = set(["client_auth"])
builder.ca = profile.ca
builder.key_usage = profile.key_usage
builder.extended_key_usage = profile.extended_key_usage
builder.subject_alt_domains = [common_name]
end_entity_cert = builder.build(private_key)
end_entity_cert_buf = asymmetric.dump_certificate(end_entity_cert)

View File

@ -281,8 +281,8 @@ def certidude_enroll(fork, renew, no_wait, kerberos, skip_self):
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)
if not re.match(const.RE_COMMON_NAME, common_name):
raise ValueError("Supplied common name %s doesn't match the expression %s" % (common_name, const.RE_COMMON_NAME))
################################
@ -338,7 +338,7 @@ def certidude_enroll(fork, renew, no_wait, kerberos, skip_self):
try:
renewal_overlap = clients.getint(authority_name, "renewal overlap")
except NoOptionError: # Renewal not specified in config
except NoOptionError: # Renewal not configured
renewal_overlap = None
try:
@ -1169,6 +1169,14 @@ def certidude_setup_authority(username, kerberos_keytab, nginx_config, country,
fh.write(env.get_template("server/builder.conf").render(vars()))
click.echo("File %s created" % const.BUILDER_CONFIG_PATH)
# Create image builder config
if os.path.exists(const.PROFILE_CONFIG_PATH):
click.echo("Signature profile config %s already exists, remove to regenerate" % const.PROFILE_CONFIG_PATH)
else:
with open(const.PROFILE_CONFIG_PATH, "w") as fh:
fh.write(env.get_template("server/profile.conf").render(vars()))
click.echo("File %s created" % const.PROFILE_CONFIG_PATH)
# Create directory with 755 permissions
os.umask(0o022)
if not os.path.exists(directory):
@ -1347,12 +1355,12 @@ def certidude_list(verbose, show_key_type, show_extensions, show_path, show_sign
@click.command("sign", help="Sign certificate")
@click.argument("common_name")
@click.option("--profile", "-p", default="default", help="Profile")
@click.option("--profile", "-p", default="rw", help="Profile")
@click.option("--overwrite", "-o", default=False, is_flag=True, help="Revoke valid certificate with same CN")
def certidude_sign(common_name, overwrite, profile):
from certidude import authority
drop_privileges()
cert = authority.sign(common_name, overwrite=overwrite, profile=profile)
cert = authority.sign(common_name, overwrite=overwrite, profile=config.PROFILES[profile])
@click.command("revoke", help="Revoke certificate")
@ -1397,6 +1405,11 @@ def certidude_serve(port, listen, fork):
from certidude import config
click.echo("Loading signature profiles:")
for profile in config.PROFILES.values():
click.echo("- %s" % profile)
click.echo()
# Rebuild reverse mapping
for cn, path, buf, cert, signed, expires in authority.list_signed():
by_serial = os.path.join(config.SIGNED_BY_SERIAL_DIR, "%x.pem" % cert.serial_number)

View File

@ -2,7 +2,9 @@ import configparser
import ipaddress
import os
from certidude import const
from certidude.profile import SignatureProfile
from collections import OrderedDict
from datetime import timedelta
# Options that are parsed from config file are fetched here
@ -96,11 +98,22 @@ TOKEN_SECRET = cp.get("token", "secret").encode("ascii")
# The API call for looking up scripts uses following directory as root
SCRIPT_DIR = cp.get("script", "path")
PROFILES = OrderedDict([[i, [j.strip() for j in cp.get("profile", i).split(",")]] for i in cp.options("profile")])
from configparser import ConfigParser
profile_config = ConfigParser()
profile_config.readfp(open(const.PROFILE_CONFIG_PATH))
PROFILES = dict([(key, SignatureProfile(key,
profile_config.get(key, "title"),
profile_config.get(key, "ou"),
profile_config.getboolean(key, "ca"),
profile_config.getint(key, "lifetime"),
profile_config.get(key, "key usage"),
profile_config.get(key, "extended key usage"),
profile_config.get(key, "common name"),
)) for key in profile_config.sections()])
cp2 = configparser.RawConfigParser()
cp2.readfp(open(const.BUILDER_CONFIG_PATH, "r"))
IMAGE_BUILDER_PROFILES = [(j, cp2.get(j, "title"), cp2.get(j, "rename")) for j in cp2.sections()]
TOKEN_OVERWRITE_PERMITTED=True

View File

@ -6,12 +6,15 @@ 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]))?$"
RE_FQDN = "^(([a-z0-9]|[a-z0-9][a-z0-9\-]*[a-z0-9])\.)+([a-z0-9]|[a-z0-9][a-z0-9\-]*[a-z0-9])?$"
RE_HOSTNAME = "^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$"
RE_COMMON_NAME = "^[A-Za-z0-9\-\.\@]+$"
RUN_DIR = "/run/certidude"
CONFIG_DIR = "/etc/certidude"
SERVER_CONFIG_PATH = os.path.join(CONFIG_DIR, "server.conf")
BUILDER_CONFIG_PATH = os.path.join(CONFIG_DIR, "builder.conf")
PROFILE_CONFIG_PATH = os.path.join(CONFIG_DIR, "profile.conf")
CLIENT_CONFIG_PATH = os.path.join(CONFIG_DIR, "client.conf")
SERVICES_CONFIG_PATH = os.path.join(CONFIG_DIR, "services.conf")
SERVER_PID_PATH = os.path.join(RUN_DIR, "server.pid")

52
certidude/profile.py Normal file
View File

@ -0,0 +1,52 @@
import click
from datetime import timedelta
from certidude import const
class SignatureProfile(object):
def __init__(self, slug, title, ou, ca, lifetime, key_usage, extended_key_usage, common_name):
self.slug = slug
self.title = title
self.ou = ou or None
self.ca = ca
self.lifetime = lifetime
self.key_usage = set(key_usage.split(" ")) if key_usage else set()
self.extended_key_usage = set(extended_key_usage.split(" ")) if extended_key_usage else set()
if common_name.startswith("^"):
self.common_name = common_name
elif common_name == "RE_HOSTNAME":
self.common_name = const.RE_HOSTNAME
elif common_name == "RE_FQDN":
self.common_name = const.RE_FQDN
elif common_name == "RE_COMMON_NAME":
self.common_name = const.RE_COMMON_NAME
else:
raise ValueError("Invalid common name constraint %s" % common_name)
@classmethod
def from_cert(self, cert):
"""
Derive signature profile from an already signed certificate, eg for renewal
"""
lifetime = cert["tbs_certificate"]["validity"]["not_after"].native.replace(tzinfo=None) - \
cert["tbs_certificate"]["validity"]["not_before"].native.replace(tzinfo=None)
return SignatureProfile(
None, "Renewal", cert.subject.native.get("organizational_unit_name"),
cert.ca, lifetime.days,
" ".join(cert.key_usage_value.native),
" ".join(cert.extended_key_usage_value.native), "^")
def serialize(self):
return dict([(key, getattr(self,key)) for key in (
"slug", "title", "ou", "ca", "lifetime", "key_usage", "extended_key_usage", "common_name")])
def __repr__(self):
bits = []
if self.lifetime >= 365:
bits.append("%d years" % (self.lifetime / 365))
if self.lifetime % 365:
bits.append("%d days" % (self.lifetime % 365))
return "%s (title=%s, ca=%s, ou=%s, lifetime=%s, key_usage=%s, extended_key_usage=%s, common_name=%s)" % (
self.slug, self.title, self.ca, self.ou, " ".join(bits), self.key_usage, self.extended_key_usage, self.common_name)

View File

@ -30,11 +30,10 @@
</button>
<div class="dropdown-menu">
{% for p in session.authority.signature.profiles %}
<a class="dropdown-item{% if p.server and not request.server %} disabled{% endif %}"
{% if p.server and not request.server %}title="Resubmit with FQDN as common name"{% endif %}
href="#" onclick="javascript:$.ajax({url:'/api/request/{{request.common_name}}/?sha256sum={{ request.sha256sum }}&profile={{ p.name }}',type:'post'});">
{% if p.title %}{{ p.title }} ({% if p.server %}server{% else %}client{% endif %}){% else %}
{% if p.server %}Server{% else %}Client{% endif %}{% endif %}, expires in {{ p.lifetime }} days</a>
<a class="dropdown-item{% if not request.common_name.match(p.common_name) %} disabled{% endif %}"
{% if not request.common_name.match(p.common_name) %} title="Common name doesn't match expression {{ p.common_name }}"{% endif %}
href="#" onclick="javascript:$.ajax({url:'/api/request/{{request.common_name}}/?sha256sum={{ request.sha256sum }}&profile={{ p.slug }}',type:'post'});">
{{ p.title }}, expires in {{ p.lifetime }} days</a>
{% endfor %}
</div>
</div>

View File

@ -0,0 +1,52 @@
[DEFAULT]
ou =
lifetime = 120
ca = false
common name = RE_COMMON_NAME
key usage = digital_signature key_encipherment
extended key usage =
[ca]
title = Certificate Authority
common name = ^ca
ca = true
key usage = key_cert_sign crl_sign
extended key usage =
lifetime = 1095
[rw]
title = Roadwarrior
ou = Roadwarrior
common name = RE_HOSTNAME
extended key usage = client_auth
[srv]
title = Server
;ou = Server
common name = RE_FQDN
lifetime = 365
extended key usage = server_auth
[gw]
title = Gateway
ou = Gateway
common name = RE_FQDN
renewable = true
lifetime = 30
extended key usage = server_auth 1.3.6.1.5.5.8.2.2 client_auth
[ap]
title = Access Point
ou = Access Point
common name = RE_HOSTNAME
lifetime = 1825
extended key usage = client_auth
[mfp]
title = Printers
ou = Printers
common name = ^mfp\-
lifetime = 30
extended key usage = client_auth

View File

@ -194,14 +194,6 @@ lifetime = 30
# Secret for generating and validating tokens, regenerate occasionally
secret = {{ token_secret }}
[profile]
# name, flags, lifetime, organizational unit, title
default = client, 120, Roadwarrior, Roadwarrior
gw = server, 30, Gateway, Gateway
srv = server, 365, Server,
ap = client, 1825, Access Point, Access Point
mfp = client, 30, MFP, Printers
[script]
# Path to the folder with scripts that can be served to the clients, set none to disable scripting
path = {{ script_dir }}