{ pkgs, config, lib, options, ... }: with lib; let cfg = config.seacrit; pathOf = name: cfg.storePath + "/store/${name}"; hash = name: conf: builtins.hashString "sha256" ( builtins.hashString "sha256" conf.owner + builtins.hashString "sha256" conf.group + builtins.hashString "sha256" conf.mode + builtins.hashString "sha256" name + builtins.hashFile "sha256" (pathOf name)); secret = types.submodule (self@{ name, config, ... }: { options = { path = mkOption { type = types.path; readOnly = true; description = mdDoc "Decrypted secret path. Read-only, for use in interpolations."; }; owner = mkOption { default = "root"; type = types.str; description = mdDoc "Owner of decrypted secret."; }; group = mkOption { default = "root"; type = types.str; description = mdDoc "Group of decrypted secret."; }; mode = mkOption { default = "0400"; type = types.str; description = mdDoc "Mode of decrypted secret, as in chmod."; }; }; config = { path = "/run/seacrit/${name}-${hash name config}"; }; }); hostKeyArgs = concatMapStringsSep " " (key: "-i ${escapeShellArg key}") cfg.hostKeys; decrypt = name: { path, owner, group, mode }: let maybe0 = s: if s == "root" then "0" else escapeShellArg s; secret = pathOf name; in '' if [ "$NIXOS_ACTION" = dry-activate ]; then echo would decrypt secret ${escapeShellArg name} else echo decrypting secret ${escapeShellArg name} ( set -eu trap "rm -f ${escapeShellArg path}; echo decrypting secret ${escapeShellArg name} failed" ERR umask 022 mkdir -p ${escapeShellArg (dirOf path)} umask 377 ${pkgs.age}/bin/age -d ${hostKeyArgs} -o ${escapeShellArg path} ${secret} chown ${maybe0 owner}:${maybe0 group} ${path} chmod ${escapeShellArg mode} ${escapeShellArg path} ) fi ''; activate = secrets: concatStringsSep "\n" (mapAttrsToList decrypt secrets); isRootSecret = s: all (id: id == "root" || id == "0") [ s.owner s.group ]; in { options.seacrit = { storePath = mkOption { type = types.nullOr types.path; description = mdDoc '' Store path to pull secrets from during build. Must contain a secrets.nix file describing all secrets. ''; default = null; }; hostID = mkOption { type = types.str; description = mdDoc "Identifier of this host in the registry."; default = config.networking.hostName; defaultText = literalExpression "config.networking.hostName"; }; registry = mkOption { type = types.submodule (args: { options = { users = mkOption { type = types.attrsOf types.str; description = mdDoc "Users the secrets system knows about, and their public keys."; default = []; }; hosts = mkOption { type = types.attrsOf types.str; description = mdDoc '' Hosts the secrets system knows about, and their public keys. Keys are matched against {option}`${options.seacrit.hostID}`. ''; default = []; }; default = mkOption { type = types.listOf types.str; description = mdDoc "Keys with access to all secrets configured here."; default = []; }; secrets = mkOption { type = types.attrsOf (types.listOf types.str); description = mdDoc '' Configured secrets, and the keys that can read them. Keys listed in {option}`${args.options.default}` are added automatically. ''; default = {}; }; }; }); readOnly = true; description = mdDoc "Content of `\${${options.seacrit.storePath}}/secrets.nix`."; example = literalExpression '' rec { users = { deploy = ""; admin = ""; }; hosts = { host1 = "host1 ssh host public key"; host2 = "host2 ssh host public key"; }; default = [ users.admin ]; secrets = { "website.pem" = [ hosts.host1 ]; "mail.website.pem" = [ hosts.host2 users.deploy ]; }; } ''; }; hostKeys = mkOption { type = types.listOf types.path; default = optionals config.services.openssh.enable (concatMap (k: optional (elem k.type [ "ed25519" "rsa" ]) k.path) config.services.openssh.hostKeys); defaultText = literalMD '' compatible keys from ${options.services.openssh.hostKeys} (ie ed25519 and rsa) ''; description = mdDoc '' Paths to keys used for secret decryption. All age key types are supported. ''; }; secrets = mkOption { type = types.attrsOf secret; description = mdDoc "Configuration for individual secrets configured through the registry."; default = {}; }; }; config = mkIf (cfg.secrets != null) { seacrit.registry = if cfg.storePath != null then import (cfg.storePath + "/secrets.nix") else {}; assertions = [ { assertion = length cfg.hostKeys > 0; message = "cannot use seacrit without any host keys"; } { assertion = cfg.storePath != null; message = "seacrit store path must be set"; } ] ++ lib.optionals (!config.users.mutableUsers) ( mapAttrsToList (n: sec: { assertion = (sec.owner == "0" || config.users.users ? ${sec.owner}) && (sec.group == "0" || config.users.groups ? ${sec.group}); message = "secret ${n} assigned to incorrect owners ${sec.owner}:${sec.group}"; }) cfg.secrets ) ++ map (name: { assertion = all (s: ! elem s [ "" "." ".." ]) (builtins.split "/" name); message = "secret name ${name} is invalid"; }) (attrNames cfg.secrets) ++ ( let r = cfg.registry; localKey = r.hosts.${cfg.hostID} or null; have = sec: elem localKey sec; haveAll = have r.default; in map (n: { assertion = r.secrets ? ${n} && (haveAll || have r.secrets.${n}); message = "secret ${n} not configured for ${cfg.hostID}"; }) (attrNames cfg.secrets) ); system.activationScripts = { # activate root secrets very early so we have access to them in the activation scripts seacrit-root = { deps = [ "specialfs" ]; text = '' if [ "$NIXOS_ACTION" != dry-activate ]; then rm -rf /run/seacrit mkdir -m 0755 /run/seacrit fi ${activate (filterAttrs (_: s: isRootSecret s) cfg.secrets)} ''; supportsDryActivation = true; }; users.deps = [ "seacrit-root" ]; seacrit = { deps = [ "users" ]; text = '' ${activate (filterAttrs (_: s: ! isRootSecret s) cfg.secrets)} ''; supportsDryActivation = true; }; }; }; }