{ pkgs, config, lib, ... }: 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 = "Decrypted secret path. Read-only, for use in interpolations."; }; owner = mkOption { default = "root"; type = types.str; description = "Owner of decrypted secret."; }; group = mkOption { default = "root"; type = types.str; description = "Group of decrypted secret."; }; mode = mkOption { default = "0400"; type = types.str; description = "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 '' ( 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} ) ''; 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 = '' 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 = "Identifier of this host in the registry."; default = config.networking.hostName; }; registry = mkOption { type = types.submodule { options = { users = mkOption { type = types.attrsOf types.str; description = "Users the secrets system knows about, and their public keys."; default = []; }; hosts = mkOption { type = types.attrsOf types.str; description = '' Hosts the secrets system knows about, and their public keys. Keys are matched against . ''; default = []; }; default = mkOption { type = types.listOf types.str; description = "Keys with access to all secrets configured here."; default = []; }; secrets = mkOption { type = types.attrsOf (types.listOf types.str); description = '' Configured secrets, and the keys that can read them. Keys listed in are added automatically. ''; default = {}; }; }; }; readOnly = true; description = "Content of ${storePath}/secrets.nix."; example = literalExample '' 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); description = "Paths to keys used for secret decryption. All age key types are supported."; }; secrets = mkOption { type = types.attrsOf secret; description = "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 = stringAfter [ "specialfs" ] '' rm -rf /run/seacrit mkdir -m 0755 /run/seacrit ${activate (filterAttrs (_: s: isRootSecret s) cfg.secrets)} ''; users.deps = [ "seacrit-root" ]; groups.deps = [ "seacrit-root" ]; seacrit = stringAfter [ "users" "groups" ] '' ${activate (filterAttrs (_: s: ! isRootSecret s) cfg.secrets)} ''; }; }; }