summaryrefslogtreecommitdiff
path: root/modules
diff options
context:
space:
mode:
Diffstat (limited to 'modules')
-rw-r--r--modules/default.nix213
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)}
+ '';
+ };
+ };
+}