{ 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)}
'';
};
};
}