From 264dde6b1fb6521a16b6b04f6976f16be851c9ae Mon Sep 17 00:00:00 2001 From: James Andariese Date: Sat, 15 Oct 2022 16:50:35 -0500 Subject: [PATCH] add the cascade cert client --- common/cascade-cert-client.nix | 301 +++++++++++++++++++++++++++++ common/deterministic-passwords.nix | 37 +++- functions/isMinutessTimeUnit.nix | 1 + functions/isSecondsTimeUnit.nix | 1 + 4 files changed, 338 insertions(+), 2 deletions(-) create mode 100644 common/cascade-cert-client.nix create mode 100644 functions/isMinutessTimeUnit.nix create mode 100644 functions/isSecondsTimeUnit.nix diff --git a/common/cascade-cert-client.nix b/common/cascade-cert-client.nix new file mode 100644 index 0000000..06ca651 --- /dev/null +++ b/common/cascade-cert-client.nix @@ -0,0 +1,301 @@ +{pkgs, lib, config, ...}: +with lib; +with builtins; +with import ; + +let cfg = config.security.cascade-certs; + shq = lib.escapeShellArg; + hostName = config.networking.hostName; + domain = config.networking.domain; + confToTimer = n: {enabled, minSecondsRemaining, ...}@opts: mkIf enabled { + wantedBy = [ "multi-user.target" "timers.target" ]; + timerConfig = { + OnUnitActiveSec = "${toString (minSecondsRemaining / 30)}"; + OnStartupSec = "5s"; + AccuracySec = mkIf (minSecondsRemaining < 1800) (mkDefault "1s"); + }; + }; + + confToService = n: {enabled, readers, minSecondsRemaining, readerGroups, vaultPkiRole, vaultPkiPath, issueOptions, ...}@opts: mkIf enabled { + serviceConfig = { + Type = "oneshot"; + }; + startLimitIntervalSec = 0; + script = let + certFolder = "${cfg.destination}/${n}"; + caFolder = "${cfg.destination}/@trust"; + in '' + set -x + PATH="${pkgs.curl}/bin:${pkgs.coreutils}/bin:${pkgs.jq}/bin:${pkgs.openssl}/bin:${pkgs.acl}/bin:$PATH" + + if [ -r ${shq certFolder}/cert.pem ] \ + && [ -h ${shq certFolder}/current ] \ + && openssl x509 -checkend ${shq minSecondsRemaining} -in ${shq certFolder}/cert.pem ;then + 1>&2 echo "Certificate will not expire in the next "${shq minSecondsRemaining}" seconds. exiting." + exit 0 + fi + + # we tell curl to also trust our own machine cert if vault presents it. + # this allows the vault server to bootstrap itself + CAOPTS="" + [ -r ${shq certFolder}/cert.pem ] && CAOPTS="--cacert "${shq certFolder}/cert.pem + [ -r ${shq certFolder}/ca.pem ] && CAOPTS="--cacert "${shq certFolder}/ca.pem + JSON="$( + curl \ + -X PUT -H "X-Vault-Token: $(cat ${shq cfg.vaultTokenPath} | tr -d '\n')" \ + -H "X-Vault-Request: true" \ + -d ${shq (toJSON issueOptions)} \ + ${cfg.vaultAddr}/v1/${vaultPkiPath}/issue/${vaultPkiRole} + )" + ERRORS="$(echo "$JSON" | jq -r '.errors // [] | .[]')" + WARNINGS="$(echo "$JSON" | jq -r '.warnings // [] | .[]')" + echo "$ERRORS" | grep . && exit 1 + echo "$WARNINGS" + while true;do + DATE="$(date --iso-8601=seconds)" + NEW_CERTS=${shq certFolder}"/.$DATE" + [ -d "$NEW_CERTS" ] || break # if we found an unused one. + done + + CURRENT_CERTS=${shq certFolder}"/current" + VIRTUAL_CERTS=${shq certFolder} + + # first, create our cert folder and fix up the rest of the structure if it had to be created. + # we mostly need to ensure it's accessible by the correct users. + mkdir -p "$NEW_CERTS" + + # we're gonna do some pointless copying in order _try_ to preserve permissions made on the host + # relax! it's all meaningless anyway. + + for f in cert.pem key.pem cert-chain.pem ca.pem;do + # copy the current one to preserve its ACLs + [ -e "$f" ] && cp -a "$f" "$NEW_CERTS/$f" + ln -sf "$CURRENT_CERTS"/"$f" ${shq certFolder}/"$f" + done + + # ensure everything is _secure_ though. + # this will reset nearly everything except that it will + # preserve ACLs + chown -R root:root ${shq certFolder} + chown root:root ${shq cfg.destination} + find ${shq certFolder} -type f -exec chmod go=,u=rw {} + + find ${shq certFolder} -type d -exec chmod go=,u=rwx {} + + + # then we truncate and append to the newly created files + + umask 077 + echo "$JSON" | jq -r '.data.certificate' > "$NEW_CERTS"/cert.pem + echo "$JSON" | jq -r '.data.private_key' > "$NEW_CERTS"/key.pem + echo "$JSON" | jq -r '.data.ca_chain[]' > "$NEW_CERTS"/ca.pem + echo "$JSON" | jq -r '.data.certificate, .data.ca_chain[]' > "$NEW_CERTS"/cert-chain.pem + + ${if (readers == []) then "# " else ""}setfacl -R ${escapeShellArgs (lib.flatten (map (n: ["-m" "u:${n}:rX"]) readers))} ${shq certFolder} + ${if (readerGroups == []) then "# " else ""}setfacl -R ${escapeShellArgs (lib.flatten (map (n: ["-m" "g:${n}:rX"]) readerGroups))} ${shq certFolder} + + umask 022 + mkdir -p ${shq caFolder} + echo "$JSON" | jq -r '.data.certificate, .data.ca_chain[]' > ${shq caFolder}/${shq n}.pem + umask 077 + + # now we do some symlink dancing to update multiple paths in one atomic write + RRR=$RANDOM$RANDOM$RANDOM + ln -sf "$NEW_CERTS" "$NEW_CERTS.$RRR" + mv -T "$NEW_CERTS.$RRR" "$CURRENT_CERTS" + + # now delete any old ones + for f in ${shq certFolder}/.[0-9]*;do + if [ "$f" = "$NEW_CERTS" ];then + continue + fi + rm -rf "$f" + done + ''; + }; + + confToCAFile = n: {enabled, ...}@opts: mkIf enabled "${cfg.destination}/${n}/ca.pem"; +in + +{ + +options = with types; { + security.cascade-certs.destination = mkOption { + type = str; + default = "/etc/cascade-certs"; + description = '' + Central folder in which to store certs. + + This should be world readable and executable. + ''; + }; + security.cascade-certs.vaultAddr = mkOption { + type = str; + default = "https://vault:8200"; + description = '' + the address of the vault server from which certificates will be requested. + ''; + }; + security.cascade-certs.machineRole = mkOption { + type = str; + default = "cascade-machine"; + description = '' + vault pki role from which to request the certificate for a machine. + ''; + }; + security.cascade-certs.serviceRolePrefix = mkOption { + type = str; + default = "cascade-service--"; + description = '' + prefix to apply to a service name to create a service certificate role name. + + together, these define the vault pki role from which to request the certificate for a service. + ''; + }; + security.cascade-certs.vaultTokenPath = mkOption { + type = str; + default = "/root/.vault-token"; + description = '' + location of the vault token on disk. + ''; + }; + security.cascade-certs.clients = mkOption { + default = {}; + description = '' + CASCADE certificate clients + + Each of these will get a certificate from the authority in the + network and place it in the specified file with the specified + users able to read it. This process makes use of ACLs so these + must be supported. + + Each client will output files in ${cfg.destination} under a folder + named the same as the client in security.cascade-certs.clients. + + Their names will be cert.pem, key.pem, ca.pem, and cert-chain.pem. + + The updates of the files will be atomic. + ''; + + type = attrsOf (submodule ({config, name, ...}@args: { + config = { + vaultPkiRole = if (config.service == null) + then (mkDefault cfg.machineRole) + else (mkDefault "cfg.serviceRolePrefix${config.service}"); + + names = + let + namesFromName = n: [ n "${n}.${domain}" ]; + in + if (config.service == null) + then (mkDefault (namesFromName hostName)) + else (mkDefault (namesFromName config.service)); + + issueOptions = { + ttl = mkDefault "${toString config.ttlSeconds}s"; + alt_names = mkDefault (join-string "," config.names); + common_name = mkDefault (head config.names); + }; + minSecondsRemaining = mkDefault (config.ttlSeconds / 10 * 3); + }; + options = { + enabled = mkEnableOption "cascade cert client for ${name}" // {default = true;}; + addCA = mkEnableOption "adding ${name}'s CA to system roots" // {default = true;}; + + readers = mkOption { + type = listOf str; + description = '' + The user names who should be able to read this file. This should usually + be the name of the service(s) consuming the certificate. + ''; + default = []; + }; + + readerGroups = mkOption { + type = listOf str; + description = '' + The group names who should be able to read this file. This should usually + be the name of the service(s) consuming the certificate. This need not + and probably should not be specified if the user is already specified + explicitly in readers. + ''; + default = []; + }; + + ttlSeconds = mkOption { + type = int; + description = '' + ttl of the certificate in golang time ParseDuration format. + ''; + + default = 96*60*60; + }; + + minSecondsRemaining = mkOption { + type = int; + description = '' + minimum seconds for cert to be valid before creating a new one. + defaults to 3/10 of ttlSeconds or if set to < 1. This will also + inform the frequency of checking the expiration which is usually + polled 1/100 ttlSeconds. This is calculated as 1/30 + minSecondsRemaining, however so that the check interval will scale + with the requirement to rotate. + ''; + }; + baseName = mkOption { + type = str; + description = '' + unit base name + ''; + default = "cascade-cert-client-${name}"; + }; + + service = mkOption { + type = nullOr str; + description = '' + the service name to get a certificate for. + + if null, this will get a machine certificate instead. + ''; + default = null; + }; + + names = mkOption { + type = listOf str; + description = '' + names to enter in CN and SNI for the certificate. + + by default, this will either contain the service name or machine name + as well as that name with the domain appended. + ''; + }; + vaultPkiPath = mkOption { + type = str; + default = "pki"; + description = '' + path of the secret engine in vault to which pki requests will be routed. + ''; + }; + vaultPkiRole = mkOption { + type = str; + description = '' + the vault role to + ''; + }; + issueOptions = with types; mkOption { + type = attrsOf str; + description = '' + options to add to the request in addition to common_name, alt_names, ttl. + ''; + default = {}; + }; + }; + })); + }; +}; + +config = { + systemd.timers = mapAttrs' (n: v: nameValuePair "${v.baseName}" (confToTimer n v)) cfg.clients; + systemd.services = mapAttrs' (n: v: nameValuePair "${v.baseName}" (confToService n v)) cfg.clients; + security.pki.certificateFiles = mapAttrsToList confToCAFile cfg.clients; +}; + +} diff --git a/common/deterministic-passwords.nix b/common/deterministic-passwords.nix index 74d2e24..9ac84c4 100644 --- a/common/deterministic-passwords.nix +++ b/common/deterministic-passwords.nix @@ -88,6 +88,28 @@ options = with types; { description = ''Where to save the secret''; }; + renew = mkOption { + type = nullOr str; + description = '' + a systemd time interval (systemd.time(7)) or null + + specifies the interval after which the secret will be renewed + by recomputing it from its namespace, running the writer, and + running the after action. this occurs after the first time + the secret is written and on every boot. + + if set to null, the default, this will only take place when the + namespace secret is refreshed. + + prod sniff test! the default is the _safest_, not the _best_. + if the writer and action are cheap, consider putting this on a + loop to ensure there is no issue caused by accidental usage of + NFS or similar issues which can disrupt the async notification + mechanisms in the kernel. + ''; + default = null; + }; + writer = mkOption { default = '' echo "$secret" > "$destination" @@ -149,6 +171,13 @@ let shq = escapeShellArg; fi ) ''; + secretToTimer = n: {enabled, renew, ...}@opts: mkIf (enabled) { + wantedBy = [ "multi-user.target" "timers.target" ]; + timerConfig.OnUnitActiveSec = renew; + timerConfig.OnStartupSec = "3s"; + timerConfig.AccuracySec = mkIf (isSecondsTimeUnit renew) "1s"; + partOf = [ "deterministic-password-setup-${n}.path" ]; + }; secretToPath = n: {enabled, destination, group, user, mode, action, ...}@opts: mkIf (enabled) { wantedBy = [ "multi-user.target" ]; pathConfig = { @@ -161,10 +190,10 @@ let shq = escapeShellArg; before = before; script = makeUpdateScript n opts; }; - secretToAfterPath = n: {enabled, ...}@opts: mkIf (enabled) { + secretToAfterPath = n: {enabled, destination, ...}@opts: mkIf (enabled) { wantedBy = [ "multi-user.target" ]; pathConfig = { - PathChanged = "/etc/cascade/deterministic-passwords/namespaces/${ns opts}"; + PathChanged = destination; }; }; secretToAfterService = n: {action, before, enabled, ...}@opts: mkIf (enabled) { @@ -178,6 +207,7 @@ let shq = escapeShellArg; secretToActivationScript = n: {enabled, destination, ...}@opts: { text = mkMerge [ (mkIf (!enabled) ''rm -f ${shq destination}'') + (mkIf (enabled) ''systemctl start deterministic-password-setup-${n}.service'') ]; }; secretToGenerateNamespace = n: opts: let namespace = ns opts; in '' @@ -186,6 +216,9 @@ let shq = escapeShellArg; in { systemd.targets."deterministic-passwords".wants = [ "paths.target" ]; + systemd.timers = ( + (mapAttrs' (n: v: nameValuePair "deterministic-password-setup-${n}" (secretToTimer n v)) config.environment.deterministic-passwords.secrets) + ); systemd.paths = ( (mapAttrs' (n: v: nameValuePair "deterministic-password-setup-${n}" (secretToPath n v)) config.environment.deterministic-passwords.secrets) // diff --git a/functions/isMinutessTimeUnit.nix b/functions/isMinutessTimeUnit.nix new file mode 100644 index 0000000..de81468 --- /dev/null +++ b/functions/isMinutessTimeUnit.nix @@ -0,0 +1 @@ +n: if n == null then false else builtins.match "[0-9] *m(in)?" n != null diff --git a/functions/isSecondsTimeUnit.nix b/functions/isSecondsTimeUnit.nix new file mode 100644 index 0000000..2f853db --- /dev/null +++ b/functions/isSecondsTimeUnit.nix @@ -0,0 +1 @@ +n: if n == null then false else builtins.match "[0-9] *(s(ec)?|$)" n != null