CASCADE/common/deterministic-passwords.nix

246 lines
8.8 KiB
Nix

# generate passwords using hashes and a root key
#
# uses util-linux uuidgen as a KDF with custom namespaces to generate passwords across a network.
#
#
{pkgs, lib, config, ...}:
with lib;
with builtins;
with import <cascade/functions>;
{
options = with types; {
environment.deterministic-passwords.namespaces = mkOption {
type = listOf str;
default = [];
description = ''
Namespaces to be made available on the host.
For maximum security, this should _not_ contain anything unused on the host.
Any namespaces used by the Nix-based deterministic passwords module will be
included automatically. This can usually be left as default.
'';
};
system.build.deterministic-passwords.generateNamespaces = mkOption {
type = str;
description = ''
command which expects a $ROOT variable and which produce namespace UUID secrets.
defaults to an automatically generated version.
'';
};
environment.deterministic-passwords.namespace-folder = mkOption {
type = str;
default = "/etc/cascade/deterministic-passwords/namespaces";
description = ''
Folder where namespace secrets are held.
'';
};
environment.deterministic-passwords.secrets = mkOption {
default = {};
description = ''
Secrets to be placed on disk.
'';
type = with types; attrsOf (submodule ({config, name, ...}@args: {
options = {
enabled = mkEnableOption "password writer for ${name}" // {default = true;};
namespace = mkOption {
type = str;
description = ''
Namespace in which to create password.
This is a namespace-prefixed, colon-delimited, hostname-compatible
name which usually will represent the scope of members which should
know the secret. For hosts, the namespace prefix is "host:". I.e.,
this will be "host:chimecho" for secrets which should only be known
to the host named "chimecho". It might instead be "service:vault"
for secrets which should be known to a cluster serving vault, such
as the database password.
Defaults to the hostname if the string is empty or not set.
'';
default = "";
};
group = mkOption {
type = str;
description = "Group that will own the secret.";
default = "root";
};
user = mkOption {
type = str;
description = "User who will own the secret.";
default = "root";
};
mode = mkOption {
default = "0400";
type = str;
description = "Target file mode (octal)";
};
destination = mkOption {
type = str;
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"
'';
type = str;
#merge = xl: join-string "\n" (map (cmd: "bash -c ${shq cmd}") xl);
description = ''
Actions to perform on the remote host to create the new secret on disk.
$secret is the secret.
$destination is the path to the destination file.
'';
};
before = mkOption {
default = [];
type = listOf str;
description = ''
systemd units depending on this password being in place should be
listed here.
this translates directly to systemd.services."deterministic-passwords-''${NAME}".before
systemd.services."deterministic-passwords-''${NAME}".requiredBy
'';
};
action = mkOption {
default = "";
type = str;
#merge = xl: join-string "\n" (map (cmd: "bash -c ${shq cmd}") xl);
description = ''
actions to perform after the secret has been written. these actions MAY occur after the
units in "before" are allowed to start.
'';
};
};
}));
};
};
config =
let shq = escapeShellArg;
ns = {namespace,...}@opts: if namespace == "" then "host:${config.networking.hostName}" else namespace;
makeUpdateScript = n: {enabled, writer, destination, group, user, mode, action, ...}@opts: ''
( set -e; export PATH="$PATH:/run/current-system/sw/sbin:/run/current-system/sw/bin"
# mkdir here instead of in a global activation script for simplicity.
mkdir -p ${shq config.environment.deterministic-passwords.namespace-folder}
if [ -e /etc/cascade/deterministic-passwords/namespaces/${ns opts} ];then
umask 0277
mkdir -p "$(dirname ${shq destination})"
(
export secret="$( ${pkgs.util-linux}/bin/uuidgen -s -n $(cat /etc/cascade/deterministic-passwords/namespaces/${ns opts}) -N ${shq n} )"
export destination=${shq destination}
bash -c ${shq writer}
)
chown ${shq user}:${shq group} ${shq destination}
chmod ${shq mode} ${shq destination}
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 = {
PathChanged = "/etc/cascade/deterministic-passwords/namespaces/${ns opts}";
};
};
secretToService = n: {enabled, before, ...}@opts: mkIf (enabled) {
serviceConfig.Type = "oneshot";
requiredBy = before;
before = before;
script = makeUpdateScript n opts;
};
secretToAfterPath = n: {enabled, destination, ...}@opts: mkIf (enabled) {
wantedBy = [ "multi-user.target" ];
pathConfig = {
PathChanged = destination;
};
};
secretToAfterService = n: {action, before, enabled, ...}@opts: mkIf (enabled) {
serviceConfig.Type = "oneshot";
after = [ "deterministic-password-setup-${n}.service" ];
script = ''
set -e; export PATH="$PATH:/run/current-system/sw/sbin:/run/current-system/sw/bin"
${action}
'';
};
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 ''
printf 'echo %q > %q\n' "$(${pkgs.util-linux}/bin/uuidgen -s -n "$UUID" -N ${shq namespace})" ${shq config.environment.deterministic-passwords.namespace-folder}/${shq namespace}
'';
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)
//
(mapAttrs' (n: v: nameValuePair "deterministic-password-after-${n}" (secretToAfterPath n v)) config.environment.deterministic-passwords.secrets)
);
systemd.services = (
(mapAttrs' (n: v: nameValuePair "deterministic-password-setup-${n}" (secretToService n v)) config.environment.deterministic-passwords.secrets)
//
(mapAttrs' (n: v: nameValuePair "deterministic-password-after-${n}" (secretToAfterService n v)) config.environment.deterministic-passwords.secrets)
);
system.build.deterministic-passwords.generateNamespaces = ''
if [ -z "$UUID" ];then
1>&2 echo "the root UUID must be in the UUID environment variable"
exit 1
fi
printf '(set -e; X=%q; mkdir -p "$X"; rm -f "$X"/*:*; )\n' ${shq config.environment.deterministic-passwords.namespace-folder}
(
${ join-string "\n" (mapAttrsToList (n: v: (secretToGenerateNamespace n v)) config.environment.deterministic-passwords.secrets) }
true ; ) | ${pkgs.coreutils}/bin/sort | ${pkgs.coreutils}/bin/uniq
'';
};
}