dotfiles/os/password/default.nix

226 lines
11 KiB
Nix

{ 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 -ctime +60 | 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
'';
# -ctime +60 is so it is possible to boot from previous nixpkgs without missing transform hashes
# TODO Find a better mechanism, maybe à la bootspec, or something compatible with cross-arch
})} $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`.";
};
};
}));
};
};
};
}