Skip to content

Commit d2bd7f4

Browse files
ianmurphy1mergify[bot]
authored andcommitted
Implement darwin module for sops-nix
1 parent 4c91d52 commit d2bd7f4

File tree

7 files changed

+514
-6
lines changed

7 files changed

+514
-6
lines changed

flake.nix

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@
3232
};
3333
homeManagerModules.sops = ./modules/home-manager/sops.nix;
3434
homeManagerModule = self.homeManagerModules.sops;
35+
darwinModules = {
36+
sops = ./modules/nix-darwin;
37+
default = self.darwinModules.sops;
38+
};
3539
packages = forAllSystems (system:
3640
import ./default.nix {
3741
pkgs = import nixpkgs {inherit system;};

modules/nix-darwin/default.nix

Lines changed: 339 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,339 @@
1+
{ config, options, lib, pkgs, ... }:
2+
3+
let
4+
cfg = config.sops;
5+
sops-install-secrets = cfg.package;
6+
manifestFor = pkgs.callPackage ./manifest-for.nix {
7+
inherit cfg;
8+
inherit (pkgs) writeTextFile;
9+
};
10+
manifest = manifestFor "" regularSecrets {};
11+
12+
pathNotInStore = lib.mkOptionType {
13+
name = "pathNotInStore";
14+
description = "path not in the Nix store";
15+
descriptionClass = "noun";
16+
check = x: !lib.path.hasStorePathPrefix (/. + x);
17+
merge = lib.mergeEqualOption;
18+
};
19+
20+
regularSecrets = lib.filterAttrs (_: v: !v.neededForUsers) cfg.secrets;
21+
22+
withEnvironment = import ./with-environment.nix {
23+
inherit cfg lib;
24+
};
25+
secretType = lib.types.submodule ({ config, ... }: {
26+
config = {
27+
sopsFile = lib.mkOptionDefault cfg.defaultSopsFile;
28+
sopsFileHash = lib.mkOptionDefault (lib.optionalString cfg.validateSopsFiles "${builtins.hashFile "sha256" config.sopsFile}");
29+
};
30+
options = {
31+
name = lib.mkOption {
32+
type = lib.types.str;
33+
default = config._module.args.name;
34+
description = ''
35+
Name of the file used in /run/secrets
36+
'';
37+
};
38+
key = lib.mkOption {
39+
type = lib.types.str;
40+
default = config._module.args.name;
41+
description = ''
42+
Key used to lookup in the sops file.
43+
No tested data structures are supported right now.
44+
This option is ignored if format is binary.
45+
'';
46+
};
47+
path = lib.mkOption {
48+
type = lib.types.str;
49+
default = if config.neededForUsers then "/run/secrets-for-users/${config.name}" else "/run/secrets/${config.name}";
50+
defaultText = "/run/secrets-for-users/$name when neededForUsers is set, /run/secrets/$name when otherwise.";
51+
description = ''
52+
Path where secrets are symlinked to.
53+
If the default is kept no symlink is created.
54+
'';
55+
};
56+
format = lib.mkOption {
57+
type = lib.types.enum ["yaml" "json" "binary" "dotenv" "ini"];
58+
default = cfg.defaultSopsFormat;
59+
description = ''
60+
File format used to decrypt the sops secret.
61+
Binary files are written to the target file as is.
62+
'';
63+
};
64+
mode = lib.mkOption {
65+
type = lib.types.str;
66+
default = "0400";
67+
description = ''
68+
Permissions mode of the in octal.
69+
'';
70+
};
71+
owner = lib.mkOption {
72+
type = with lib.types; nullOr str;
73+
default = "root";
74+
description = ''
75+
User of the file. Can only be set if uid is 0.
76+
'';
77+
};
78+
uid = lib.mkOption {
79+
type = with lib.types; nullOr int;
80+
default = 0;
81+
description = ''
82+
UID of the file, only applied when owner is null. The UID will be applied even if the corresponding user doesn't exist.
83+
'';
84+
};
85+
group = lib.mkOption {
86+
type = with lib.types; nullOr str;
87+
default = "staff";
88+
defaultText = "staff";
89+
description = ''
90+
Group of the file. Can only be set if gid is 0.
91+
'';
92+
};
93+
gid = lib.mkOption {
94+
type = with lib.types; nullOr int;
95+
default = 0;
96+
description = ''
97+
GID of the file, only applied when group is null. The GID will be applied even if the corresponding group doesn't exist.
98+
'';
99+
};
100+
sopsFile = lib.mkOption {
101+
type = lib.types.path;
102+
defaultText = lib.literalExpression "\${config.sops.defaultSopsFile}";
103+
description = ''
104+
Sops file the secret is loaded from.
105+
'';
106+
};
107+
sopsFileHash = lib.mkOption {
108+
type = lib.types.str;
109+
readOnly = true;
110+
description = ''
111+
Hash of the sops file.
112+
'';
113+
};
114+
neededForUsers = lib.mkOption {
115+
type = lib.types.bool;
116+
default = false;
117+
description = ''
118+
**Warning** This option doesn't have any effect on macOS, as nix-darwin cannot manage user passwords on macOS.
119+
This can be used to retrieve user's passwords from sops-nix.
120+
Setting this option moves the secret to /run/secrets-for-users and disallows setting owner and group to anything else than root.
121+
'';
122+
};
123+
};
124+
});
125+
126+
darwinSSHKeys = [{
127+
type = "rsa";
128+
path = "/etc/ssh/ssh_host_rsa_key";
129+
} {
130+
type = "ed25519";
131+
path = "/etc/ssh/ssh_host_ed25519_key";
132+
}];
133+
134+
escapedKeyFile = lib.escapeShellArg cfg.age.keyFile;
135+
# Skip ssh keys deployed with sops to avoid a catch 22
136+
defaultImportKeys = algo:
137+
map (e: e.path) (lib.filter (e: e.type == algo && !(lib.hasPrefix "/run/secrets" e.path)) darwinSSHKeys);
138+
139+
installScript = ''
140+
${if cfg.age.generateKey then ''
141+
if [[ ! -f ${escapedKeyFile} ]]; then
142+
echo generating machine-specific age key...
143+
mkdir -p $(dirname ${escapedKeyFile})
144+
# age-keygen sets 0600 by default, no need to chmod.
145+
${pkgs.age}/bin/age-keygen -o ${escapedKeyFile}
146+
fi
147+
'' else ""}
148+
echo "Setting up secrets..."
149+
${withEnvironment "${sops-install-secrets}/bin/sops-install-secrets ${manifest}"}
150+
'';
151+
152+
in {
153+
options.sops = {
154+
secrets = lib.mkOption {
155+
type = lib.types.attrsOf secretType;
156+
default = {};
157+
description = ''
158+
Path where the latest secrets are mounted to.
159+
'';
160+
};
161+
162+
defaultSopsFile = lib.mkOption {
163+
type = lib.types.path;
164+
description = ''
165+
Default sops file used for all secrets.
166+
'';
167+
};
168+
169+
defaultSopsFormat = lib.mkOption {
170+
type = lib.types.str;
171+
default = "yaml";
172+
description = ''
173+
Default sops format used for all secrets.
174+
'';
175+
};
176+
177+
validateSopsFiles = lib.mkOption {
178+
type = lib.types.bool;
179+
default = true;
180+
description = ''
181+
Check all sops files at evaluation time.
182+
This requires sops files to be added to the nix store.
183+
'';
184+
};
185+
186+
keepGenerations = lib.mkOption {
187+
type = lib.types.ints.unsigned;
188+
default = 1;
189+
description = ''
190+
Number of secrets generations to keep. Setting this to 0 disables pruning.
191+
'';
192+
};
193+
194+
log = lib.mkOption {
195+
type = lib.types.listOf (lib.types.enum [ "keyImport" "secretChanges" ]);
196+
default = [ "keyImport" "secretChanges" ];
197+
description = "What to log";
198+
};
199+
200+
environment = lib.mkOption {
201+
type = lib.types.attrsOf (lib.types.either lib.types.str lib.types.path);
202+
default = {};
203+
description = ''
204+
Environment variables to set before calling sops-install-secrets.
205+
206+
The values are placed in single quotes and not escaped any further to
207+
allow usage of command substitutions for more flexibility. To properly quote
208+
strings with quotes use lib.escapeShellArg.
209+
210+
This will be evaluated twice when using secrets that use neededForUsers but
211+
in a subshell each time so the environment variables don't collide.
212+
'';
213+
};
214+
215+
package = lib.mkOption {
216+
type = lib.types.package;
217+
default = (pkgs.callPackage ../.. {}).sops-install-secrets;
218+
defaultText = lib.literalExpression "(pkgs.callPackage ../.. {}).sops-install-secrets";
219+
description = ''
220+
sops-install-secrets package to use.
221+
'';
222+
};
223+
224+
validationPackage = lib.mkOption {
225+
type = lib.types.package;
226+
default =
227+
if pkgs.stdenv.buildPlatform == pkgs.stdenv.hostPlatform
228+
then sops-install-secrets
229+
else (pkgs.pkgsBuildHost.callPackage ../.. {}).sops-install-secrets;
230+
defaultText = lib.literalExpression "config.sops.package";
231+
232+
description = ''
233+
sops-install-secrets package to use when validating configuration.
234+
235+
Defaults to sops.package if building natively, and a native version of sops-install-secrets if cross compiling.
236+
'';
237+
};
238+
239+
age = {
240+
keyFile = lib.mkOption {
241+
type = lib.types.nullOr pathNotInStore;
242+
default = null;
243+
example = "/var/lib/sops-nix/key.txt";
244+
description = ''
245+
Path to age key file used for sops decryption.
246+
'';
247+
};
248+
249+
generateKey = lib.mkOption {
250+
type = lib.types.bool;
251+
default = false;
252+
description = ''
253+
Whether or not to generate the age key. If this
254+
option is set to false, the key must already be
255+
present at the specified location.
256+
'';
257+
};
258+
259+
sshKeyPaths = lib.mkOption {
260+
type = lib.types.listOf lib.types.path;
261+
default = defaultImportKeys "ed25519";
262+
defaultText = lib.literalMD "The ed25519 keys from {option}`config.services.openssh.hostKeys`";
263+
description = ''
264+
Paths to ssh keys added as age keys during sops description.
265+
'';
266+
};
267+
};
268+
269+
gnupg = {
270+
home = lib.mkOption {
271+
type = lib.types.nullOr lib.types.str;
272+
default = null;
273+
example = "/root/.gnupg";
274+
description = ''
275+
Path to gnupg database directory containing the key for decrypting the sops file.
276+
'';
277+
};
278+
279+
sshKeyPaths = lib.mkOption {
280+
type = lib.types.listOf lib.types.path;
281+
default = defaultImportKeys "rsa";
282+
defaultText = lib.literalMD "The rsa keys from {option}`config.services.openssh.hostKeys`";
283+
description = ''
284+
Path to ssh keys added as GPG keys during sops description.
285+
This option must be explicitly unset if <literal>config.sops.gnupg.home</literal> is set.
286+
'';
287+
};
288+
};
289+
};
290+
imports = [
291+
./templates
292+
./secrets-for-users
293+
];
294+
295+
config = lib.mkMerge [
296+
(lib.mkIf (cfg.secrets != {}) {
297+
assertions = [{
298+
assertion = cfg.gnupg.home != null || cfg.gnupg.sshKeyPaths != [] || cfg.age.keyFile != null || cfg.age.sshKeyPaths != [];
299+
message = "No key source configured for sops. Either set services.openssh.enable or set sops.age.keyFile or sops.gnupg.home";
300+
} {
301+
assertion = !(cfg.gnupg.home != null && cfg.gnupg.sshKeyPaths != []);
302+
message = "Exactly one of sops.gnupg.home and sops.gnupg.sshKeyPaths must be set";
303+
}] ++ lib.optionals cfg.validateSopsFiles (
304+
lib.concatLists (lib.mapAttrsToList (name: secret: [{
305+
assertion = builtins.pathExists secret.sopsFile;
306+
message = "Cannot find path '${secret.sopsFile}' set in sops.secrets.${lib.strings.escapeNixIdentifier name}.sopsFile";
307+
} {
308+
assertion =
309+
builtins.isPath secret.sopsFile ||
310+
(builtins.isString secret.sopsFile && lib.hasPrefix builtins.storeDir secret.sopsFile);
311+
message = "'${secret.sopsFile}' is not in the Nix store. Either add it to the Nix store or set sops.validateSopsFiles to false";
312+
} {
313+
assertion = secret.uid != null && secret.uid != 0 -> secret.owner == null;
314+
message = "In ${secret.name} exactly one of sops.owner and sops.uid must be set";
315+
} {
316+
assertion = secret.gid != null && secret.gid != 0 -> secret.group == null;
317+
message = "In ${secret.name} exactly one of sops.group and sops.gid must be set";
318+
}]) cfg.secrets)
319+
);
320+
321+
system.build.sops-nix-manifest = manifest;
322+
system.activationScripts = {
323+
postActivation.text = lib.mkAfter installScript;
324+
};
325+
326+
launchd.daemons.sops-install-secrets = {
327+
command = installScript;
328+
serviceConfig = {
329+
RunAtLoad = true;
330+
KeepAlive = false;
331+
};
332+
};
333+
})
334+
335+
{
336+
sops.environment.SOPS_GPG_EXEC = lib.mkIf (cfg.gnupg.home != null || cfg.gnupg.sshKeyPaths != []) (lib.mkDefault "${pkgs.gnupg}/bin/gpg");
337+
}
338+
];
339+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{ writeTextFile, cfg }:
2+
3+
suffix: secrets: extraJson:
4+
5+
writeTextFile {
6+
name = "manifest${suffix}.json";
7+
text = builtins.toJSON ({
8+
secrets = builtins.attrValues secrets;
9+
# Does this need to be configurable?
10+
secretsMountPoint = "/run/secrets.d";
11+
symlinkPath = "/run/secrets";
12+
keepGenerations = cfg.keepGenerations;
13+
gnupgHome = cfg.gnupg.home;
14+
sshKeyPaths = cfg.gnupg.sshKeyPaths;
15+
ageKeyFile = cfg.age.keyFile;
16+
ageSshKeyPaths = cfg.age.sshKeyPaths;
17+
useTmpfs = false;
18+
templates = cfg.templates;
19+
placeholderBySecretName = cfg.placeholder;
20+
userMode = false;
21+
logging = {
22+
keyImport = builtins.elem "keyImport" cfg.log;
23+
secretChanges = builtins.elem "secretChanges" cfg.log;
24+
};
25+
} // extraJson);
26+
checkPhase = ''
27+
${cfg.validationPackage}/bin/sops-install-secrets -check-mode=${if cfg.validateSopsFiles then "sopsfile" else "manifest"} "$out"
28+
'';
29+
}

0 commit comments

Comments
 (0)