diff options
Diffstat (limited to 'modules')
-rw-r--r-- | modules/default.nix | 213 |
1 files changed, 213 insertions, 0 deletions
diff --git a/modules/default.nix b/modules/default.nix new file mode 100644 index 0000000..8c48542 --- /dev/null +++ b/modules/default.nix @@ -0,0 +1,213 @@ +{ 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 + <option>seacrit.hostID</option>. + ''; + 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 + <option>seacrit.registry.default</option> are added automatically. + ''; + default = {}; + }; + }; + }; + readOnly = true; + description = "Content of <literal>${storePath}/secrets.nix<literal>."; + example = literalExample '' + rec { + users = { + deploy = "<one age public key>"; + admin = "<another age public key>"; + }; + + 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)} + ''; + }; + }; +} |