{ pkgs, lib, config, ... }: let passwordStoreDir = "/etc/passwords"; passwordHash = password: builtins.hashString "sha256" "${password.path}\n${builtins.toString password.selector}\n${builtins.toString password.transform}"; passwordStorePath = password: "${passwordStoreDir}/${passwordHash password}"; describePassword = password: password.path + (if password.selector != null then " -> ${password.selector}" else "") + (if password.transform != null then " | (${password.transform})" else ""); passwordFiles = builtins.attrValues config.vivarium.passwordFiles; in { config = { system = { activationScripts.secrets = { deps = [ "users" "groups" ]; supportsDryActivation = false; # TODO text = let readPassword = password: "cat ${lib.strings.escapeShellArg (passwordStorePath password)}"; # Using awk's ENVIRON so it should resist any input? pipeSubstitute = k: v: ''K=${lib.strings.escapeShellArg k} V="$(${v})" awk '{ gsub (ENVIRON["K"], ENVIRON["V"]); print }' ''; subsitutePassword = variable: password: pipeSubstitute variable (readPassword password); subsitutePasswordFile = passwordFile: lib.mapAttrsToList subsitutePassword passwordFile.passwords; renderPasswordFile = passwordFile: "${lib.strings.concatStringsSep "| " (subsitutePasswordFile passwordFile)} < ${passwordFile.template}"; installPasswordFile = passwordFile: '' install -C -o ${passwordFile.owner} -g ${passwordFile.group} -m ${passwordFile.mode} -d ${builtins.dirOf passwordFile.path} temp="$(mktemp)" trap 'rm "$temp"' ERR ${renderPasswordFile passwordFile} > "$temp" install -C -o ${passwordFile.owner} -g ${passwordFile.group} -m ${passwordFile.mode} -T "$temp" ${passwordFile.path} rm "$temp" trap - ERR ''; installPasswordFileApp = passwordFile: pkgs.writeShellApplication { name = builtins.replaceStrings ["/"] ["_"] passwordFile.path; runtimeInputs = with pkgs; [ gawk ]; text = installPasswordFile passwordFile; }; installPasswordFileSandboxed = passwordFile: ''${lib.getExe (installPasswordFileApp passwordFile)} || echo Failed to install ${lib.strings.escapeShellArg passwordFile.path}''; in '' echo Installing secrets... ${lib.strings.concatLines (builtins.map installPasswordFileSandboxed passwordFiles)} ''; }; extraSystemBuilderCmds = let passwords = builtins.attrValues config.vivarium.passwords; readPasswordClear = password: "pass show ${lib.strings.escapeShellArg password.path}" + ( if password.selector == null then " | head -n1" else (if password.selector == "@" then "" else " | tail -n +2 | yq -r '.${password.selector}'") ); readPassword = password: (readPasswordClear password) + (lib.strings.optionalString (password.transform != null) " | ${password.transform}"); gitPath = password: ''"$PASSWORD_STORE_DIR"/${lib.strings.escapeShellArg password.path}.gpg''; isGeneratedPassword = password: ''test -f ${gitPath password}''; dateGit = password: " " + ''(cd "$(dirname ${gitPath password})"; git log -n1 --format='format:%ct' "$(basename ${gitPath password})")'' + " "; dateStore = password: ''sudo stat -c '%Y' ${passwordStorePath password}''; isInStore = password: ''sudo test -f ${passwordStorePath password}''; testCanGenerate = password: lib.asserts.assertMsg (builtins.elem password.selector [ "@" null ]) "Unimplemented: generator + selector ${describePassword password}"; generatePassword = password: assert testCanGenerate password; ''${password.generator} | pass insert -m ${lib.strings.escapeShellArg password.path}''; raiseCantGenerate = password: ''echo "Error: no generator" ; exit 1''; syncPasswordStore = password: '' # ${describePassword password} write=false if ${isInStore password} then if ${isGeneratedPassword password} then date_store="$(${dateStore password})" date_git="$(${dateGit password})" if [ "$date_git" -eq "$date_store" ] then echo ${lib.strings.escapeShellArg (describePassword password)}: up-to-date elif [ "$date_git" -gt "$date_store" ] then echo ${lib.strings.escapeShellArg (describePassword password)}: updating write=true else echo ERROR ${lib.strings.escapeShellArg (describePassword password)}: store is more recent than git exit 1 fi else echo ERROR ${lib.strings.escapeShellArg (describePassword password)}: exists in store but not in git exit 1 fi else if ${isGeneratedPassword password} then echo ${lib.strings.escapeShellArg (describePassword password)}: installing else echo ${lib.strings.escapeShellArg (describePassword password)}: generating ${(if password.generator != null then generatePassword else raiseCantGenerate) password} fi write=true fi if [ "$write" = true ] then temp="$(mktemp)" trap 'rm "$temp"' ERR ${readPassword password} > "$temp" touch -d @"$(${dateGit password})" "$temp" sudo install -o root -g root -m u=rw -p -T "$temp" ${lib.strings.escapeShellArg (passwordStorePath password)} rm "$temp" trap - ERR fi ''; allFilenames = builtins.map (password: "${passwordStoreDir}/${passwordHash password}") passwords; in '' ln -s ${lib.getExe (pkgs.writeShellApplication { name = "update-password-store"; text = '' test -d "$PASSWORD_STORE_DIR" sudo install -C -o root -g root -m u=rwx -d "${passwordStoreDir}" ${lib.strings.concatLines (builtins.map syncPasswordStore passwords)} comm -23 <(sudo find ${passwordStoreDir} -type f | sort) <(echo ${lib.strings.escapeShellArg (lib.strings.concatLines allFilenames)} | sort) | while read -r file do echo Removing "$file" from password store sudo rm "$file" done ''; })} $out/bin/ ''; }; vivarium.passwords = let passwordsList = lib.lists.flatten (map (f: builtins.attrValues f.passwords) passwordFiles); passwordsAttrs = map (password: { ${passwordHash password} = password; }) passwordsList; in lib.attrsets.mergeAttrsList passwordsAttrs; }; options = { # Using vivarium because that's where it's from, and we don't want it in home manager's frogeye # TODO Make this cleaner, merge the two, somehow vivarium = let defaultvar = "@PASSWORD@"; passwordSubmodule = lib.types.submodule ({ ... }: { options = { path = lib.mkOption { type = lib.types.str; description = "Path to the password store entry"; }; selector = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; description = "If unset, selects the first line. If '@', select everything. If any other value, will parse the password metadata as YML and use selector (yq)."; }; generator = lib.mkOption { type = lib.types.nullOr lib.types.str; default = "${lib.getExe pkgs.pwgen} -s 32"; description = "Command to generate the password. Won't work when selector is set to read metadata."; }; transform = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; description = "Shell command to transform the password with before substitution"; }; }; }); in { passwords = lib.mkOption { default = { }; type = lib.types.attrsOf passwordSubmodule; }; passwordFiles = lib.mkOption { default = { }; type = lib.types.attrsOf (lib.types.submodule ({ name, config, ... }: { options = { path = lib.mkOption { type = lib.types.str; default = name; description = "Where to place the file."; }; mode = lib.mkOption { type = lib.types.str; default = "0400"; description = "Unix permission"; }; owner = lib.mkOption { type = lib.types.str; default = "root"; description = "Owner of the secret file"; }; group = lib.mkOption { type = lib.types.str; default = "root"; description = "Group of the secret file"; }; text = lib.mkOption { type = lib.types.str; default = defaultvar; description = "Content of the template used to make the file. Exclusive with `template`."; }; template = lib.mkOption { type = lib.types.path; default = pkgs.writeText "password-template" config.text; description = "Content of the template used to make the file. Exclusive with `text`."; }; passwords = lib.mkOption { default = lib.optionalAttrs (config.password != null) { ${defaultvar} = config.password; }; type = lib.types.attrsOf passwordSubmodule; description = "Paths to passwords that will substitute the variables in the template. Exclusive with `password`"; }; password = lib.mkOption { type = passwordSubmodule; description = "Path to password that will substitute '@PASSWORD@' in the template. Exclusive with `passwords`."; }; }; })); }; }; }; }