summaryrefslogtreecommitdiff
path: root/modules/default.nix
blob: fbbf075b0906a98d422ecfb9dd287fbecbe3cd4f (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
{ 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 ''
      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 = ''
        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 = {
        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;
      };
    };
  };
}