]> Git — Sourcephile - sourcephile-nix.git/blob - shell/modules/tools/security/gnupg.nix
nix: move to flake.nix
[sourcephile-nix.git] / shell / modules / tools / security / gnupg.nix
1 { pkgs, lib, config, ... }:
2 let
3 inherit (lib) types;
4 inherit (config) gnupg;
5 unlines = builtins.concatStringsSep "\n";
6 unwords = builtins.concatStringsSep " ";
7
8 generateKeys = keys: unlines (lib.mapAttrsToList generateKey keys);
9 generateKey =
10 uid:
11 { uid ? uid
12 , algo ? "future-default"
13 , usage ? ["default"]
14 , expire ? "-"
15 , passPath
16 , subKeys ? {}
17 , postRun ? ""
18 , ...
19 }@primary:
20 ''
21 info "generateKey uid=\"${uid}\""
22 if ! ${gpg-with-home}/bin/gpg-with-home --list-secret-keys -- "=${uid}" >/dev/null 2>/dev/null
23 then
24 ${if passPath != "" then "${pkgs.pass}/bin/pass '${passPath}'" else "cat /dev/null"} |
25 ${gpg-with-home}/bin/gpg-with-home \
26 --batch --pinentry-mode loopback --passphrase-fd 0 \
27 --quick-generate-key "${uid}" "${algo}" "${unwords usage}" "${expire}"
28 fi
29 ${head1}
30 fpr=$(${gpg-fingerprint}/bin/gpg-fingerprint -- "=${uid}" | head1)
31 caps=$(${gpg-with-home}/bin/gpg-with-home \
32 --with-colons --with-fingerprint \
33 --list-secret-keys -- "=${uid}" |
34 ${pkgs.gnugrep}/bin/grep '^ssb:' |
35 ${pkgs.coreutils}/bin/cut -d : -f 12 || true)
36 ''
37 + unlines (map (generateSubKey primary) subKeys)
38 + generateBackupKey "$fpr" primary
39 + postRun
40 ;
41 generateSubKey =
42 primary:
43 { expire ? primary.expire
44 , algo ? primary.algo
45 , usage
46 , ...
47 }:
48 ''
49 info " generateSubKey usage=[${unwords usage}]"
50 if ! printf '%s\n' "$caps" | ${pkgs.gnugrep}/bin/grep -Fqx "${lettersKeyUsage usage}"
51 then
52 ${if primary.passPath != "" then "${pkgs.pass}/bin/pass '${primary.passPath}'" else "cat /dev/null"} |
53 ${gpg-with-home}/bin/gpg-with-home \
54 --batch --pinentry-mode loopback --passphrase-fd 0 \
55 --quick-add-key "$fpr" "${algo}" "${unwords usage}" "${expire}"
56 fi
57 '';
58 generateBackupKey =
59 fpr:
60 { passPath
61 , backupRecipients ? []
62 , uid
63 , ...
64 }:
65 lib.optionalString (backupRecipients != [])
66 ''
67 info " generateBackupKey backupRecipients=[${unwords (map (s: "\\\"${s}\\\"") backupRecipients)}]"
68 mkdir -p "${gnupg.gnupgHome}/backup/${uid}/"
69 if ! test -s "${gnupg.gnupgHome}/backup/${uid}/${fpr}.pubkey.asc"
70 then
71 ${gpg-with-home}/bin/gpg-with-home \
72 --batch \
73 --armor --yes --output "${gnupg.gnupgHome}/backup/${uid}/${fpr}.pubkey.asc" \
74 --export-options export-backup \
75 --export "${fpr}"
76 fi
77 '' + (if backupRecipients == [""] then
78 ''
79 if ! test -s "${gnupg.gnupgHome}/backup/${uid}/${fpr}.revoke.asc" &&
80 ${gpg-with-home}/bin/gpg-with-home --list-secret-keys "${fpr}" | grep -q "sec "
81 then
82 ${if passPath != "" then "${pkgs.pass}/bin/pass '${passPath}'" else "cat /dev/null"} |
83 ${gpg-with-home}/bin/gpg-with-home \
84 --pinentry-mode loopback --passphrase-fd 0 \
85 --armor --yes --output "${gnupg.gnupgHome}/backup/${uid}/${fpr}.revoke.asc" \
86 --gen-revoke "${fpr}"
87 fi
88 if ! test -s "${gnupg.gnupgHome}/backup/${uid}/${fpr}.privkey.sec"
89 then
90 ${if passPath != "" then "${pkgs.pass}/bin/pass '${passPath}'" else "cat /dev/null"} |
91 ${gpg-with-home}/bin/gpg-with-home \
92 --batch --pinentry-mode loopback --passphrase-fd 0 \
93 --armor --yes --output "${gnupg.gnupgHome}/backup/${uid}/${fpr}.privkey.sec" \
94 --export-options export-backup \
95 --export-secret-key "${fpr}"
96 fi
97 if ! test -s "${gnupg.gnupgHome}/backup/${uid}/${fpr}.subkeys.sec"
98 then
99 ${if passPath != "" then "${pkgs.pass}/bin/pass '${passPath}'" else "cat /dev/null"} |
100 ${gpg-with-home}/bin/gpg-with-home \
101 --batch --pinentry-mode loopback --passphrase-fd 0 \
102 --armor --yes --output "${gnupg.gnupgHome}/backup/${uid}/${fpr}.subkeys.sec" \
103 --export-options export-backup \
104 --export-secret-subkeys "${fpr}"
105 fi
106 '' else ''
107 if ! test -s "${gnupg.gnupgHome}/backup/${uid}/${fpr}.revoke.asc.gpg"
108 then
109 ${if passPath != "" then "${pkgs.pass}/bin/pass '${passPath}'" else "cat /dev/null"} |
110 ${gpg-with-home}/bin/gpg-with-home \
111 --pinentry-mode loopback --passphrase-fd 0 \
112 --armor --gen-revoke "${fpr}" |
113 gpg --encrypt ${recipients backupRecipients} \
114 --armor --yes --output "${gnupg.gnupgHome}/backup/${uid}/${fpr}.revoke.asc.gpg"
115 fi
116 if ! test -s "${gnupg.gnupgHome}/backup/${uid}/${fpr}.privkey.sec.gpg"
117 then
118 ${if passPath != "" then "${pkgs.pass}/bin/pass '${passPath}'" else "cat /dev/null"} |
119 ${gpg-with-home}/bin/gpg-with-home \
120 --batch --pinentry-mode loopback --passphrase-fd 0 \
121 --armor --export-options export-backup \
122 --export-secret-key "${fpr}" |
123 gpg --encrypt ${recipients backupRecipients} \
124 --armor --yes --output "${gnupg.gnupgHome}/backup/${uid}/${fpr}.privkey.sec.gpg"
125 fi
126 if ! test -s "${gnupg.gnupgHome}/backup/${uid}/${fpr}.subkeys.sec.gpg"
127 then
128 ${if passPath != "" then "${pkgs.pass}/bin/pass '${passPath}'" else "cat /dev/null"} |
129 ${gpg-with-home}/bin/gpg-with-home \
130 --batch --pinentry-mode loopback --passphrase-fd 0 \
131 --armor --export-options export-backup \
132 --export-secret-subkeys "${fpr}" |
133 gpg --encrypt ${recipients backupRecipients} \
134 --armor --yes --output "${gnupg.gnupgHome}/backup/${uid}/${fpr}.subkeys.sec.gpg"
135 fi
136 '');
137 recipients = rs: unwords (map (r: ''--recipient "${refKey r}"'') rs);
138 refKey = key: if builtins.typeOf key == "string" then key else "=${key.uid}";
139 signer = s: if s == null
140 then ""
141 else ''--sign --default-key "${refKey s}"'';
142 lettersKeyUsage = usage:
143 (if builtins.elem "encrypt" usage then "e" else "") +
144 (if builtins.elem "sign" usage then "s" else "") +
145 (if builtins.elem "cert" usage then "c" else "") +
146 (if builtins.elem "auth" usage then "a" else "");
147
148 passOfFingerprint = key:
149 # Return shell code
150 # which fills a map from the fingerprints of the given key
151 # to its password file.
152 ''
153 # shell.gnupg.pass.passOfFingerprint
154 for fpr in $(${gpg-fingerprint}/bin/gpg-fingerprint -- "=${key.uid}")
155 do eval "pass_$fpr=\"${key.passPath}\""
156 done
157 '';
158 forgetPass =
159 # Return shell code
160 # which installs an exit and keyboard interruption (^C) trap
161 # removing any pass from gpg-agent
162 # whose keygrip is registered in $keygrips.
163 ''
164 # forgetPass
165 keygrips=
166 forgetPass () {
167 for keygrip in $keygrips
168 do
169 echo >&2 "gpg: forget: keygrip=$keygrip"
170 GNUPGHOME=${gnupg.gnupgHome} \
171 ${pkgs.gnupg}/bin/gpg-connect-agent </dev/null >&2 "CLEAR_PASSPHRASE $keygrip" ||
172 true
173 done
174 keygrips=
175 }
176 trap 'forgetPass' EXIT INT
177 '';
178 presetPass = keys: uid:
179 # Return shell code
180 # which preset the pass of given uid into gpg-agent,
181 # using keys to find where the pass is stored.
182 ''
183 ${unlines (map passOfFingerprint keys)}
184 # presetPass
185 GNUPGHOME=${gnupg.gnupgHome} \
186 ${pkgs.gnupg}/bin/gpgconf --launch gpg-agent
187 ${head1}
188 fpr="$(${gpg-fingerprint}/bin/fingerprint -- "${uid}" | head1)"
189 eval pass="\''${pass_$fpr}"
190 if test -n "$pass"
191 then
192 for keygrip in $(${gnupg.gpg-keygrip}/bin/gpg-keygrip -- "$fpr")
193 do
194 keygrips="$keygrips $keygrip"
195 echo >&2 "gpg: preset: keygrip=$keygrip pass=$pass"
196 ${pkgs.pass}/bin/pass "$pass" |
197 GNUPGHOME=${gnupg.gnupgHome} \
198 ${pkgs.gnupg}/libexec/gpg-preset-passphrase --preset ''${XTRACE:+--verbose} $keygrip
199 done
200 fi
201 '';
202
203 # Initialize the keyring according to gnupg.keys.
204 gpg-init = pkgs.writeShellScriptBin "gpg-init" (''
205 set -eu
206 set -o pipefail
207 ${info}
208 '' +
209 generateKeys gnupg.keys
210 );
211
212 # A wrapper around gpg to set GNUPGHOME.
213 gpg-with-home = pkgs.writeScriptBin "gpg-with-home" ''
214 GNUPGHOME=${gnupg.gnupgHome} \
215 exec ${pkgs.gnupg}/bin/gpg "$@"
216 '';
217
218 # A wrapper around gpg to get fingerprints.
219 gpg-fingerprint = pkgs.writeScriptBin "gpg-fingerprint" ''
220 set -eu
221 ${gpg-with-home}/bin/gpg-with-home \
222 --with-colons --with-fingerprint --with-subkey-fingerprint \
223 --list-public-keys "$@" |
224 while IFS=: read -r t x x x key x x x x uid x
225 do case $t in
226 (pub|sub|sec|ssb)
227 while IFS=: read -r t x x x x x x x x fpr x
228 do case $t in (fpr) printf '%s\n' "$fpr"; break;;
229 esac done
230 ;;
231 esac done
232 '';
233
234 # A wrapper around gpg to get keygrips.
235 gpg-keygrip = pkgs.writeScriptBin "gpg-keygrip" ''
236 set -eu
237 ${gpg-with-home}/bin/gpg-with-home \
238 --with-colons --with-keygrip \
239 --list-public-keys "$@" |
240 while IFS=: read -r t x x x key x x x x uid x do case $t in (pub|sub|sec|ssb) while IFS=: read -r t x x x x x x x x grp x do case $t in (grp) printf '%s\n' "$grp"; break;; esac done ;; esac done
241 '';
242
243 # A wrapper around gpg to get uids.
244 gpg-uid = pkgs.writeScriptBin "gpg-uid" ''
245 set -eu
246 ${gpg-with-home}/bin/gpg-with-home \
247 --with-colons \
248 --list-public-keys "$@" |
249 while IFS=: read -r t st x x x x x id x uid x
250 do case $t in
251 (uid)
252 case $st in
253 (u) printf '%s\n' "$uid";;
254 esac
255 ;;
256 esac done
257 '';
258
259 head1 = ''
260 head1(){
261 IFS= read -r line
262 cat >/dev/null # NOTE: consuming all the input avoids useless triggering of pipefail
263 printf %s "$line"
264 }
265 '';
266 info = ''
267 info(){
268 echo >&2 "gpg-init: $*"
269 }
270 '';
271 in
272 {
273 options.gnupg = {
274 enable = lib.mkEnableOption "GnuPG shell utilities";
275 gnupgHome = lib.mkOption {
276 type = types.str;
277 default = "sec/gnupg";
278 description = ''
279 '';
280 };
281 keys = lib.mkOption {
282 default = {};
283 example =
284 { "John Doe. <contact@example.coop>" = {
285 algo = "rsa4096";
286 expire = "1y";
287 usage = ["cert" "sign"];
288 passPath = "example.coop/gpg/contact";
289 subKeys = [
290 { algo = "rsa4096"; expire = "1y"; usage = ["sign"];}
291 { algo = "rsa4096"; expire = "1y"; usage = ["encrypt"];}
292 { algo = "rsa4096"; expire = "1y"; usage = ["auth"];}
293 ];
294 backupRecipients = ["@john@doe.pro"];
295 };
296 };
297 type = types.attrsOf (types.submodule ({name, ...}: {
298 options = {
299 uid = lib.mkOption {
300 type = types.str;
301 example = "John Doe <john.doe@example.coop>";
302 default = name;
303 description = ''
304 User ID.
305 '';
306 };
307 algo = lib.mkOption {
308 type = types.enum [ "rsa4096" ];
309 default = "future-default";
310 example = "rsa4096";
311 description = ''
312 Cryptographic algorithm.
313 '';
314 };
315 expire = lib.mkOption {
316 type = types.str;
317 default = "0";
318 example = "1y";
319 description = ''
320 Expiration timeout.
321 '';
322 };
323 usage = lib.mkOption {
324 type = with types; listOf (enum [ "cert" "sign" "encrypt" "auth" "default" ]);
325 default = ["default"];
326 example = ["cert" "sign" "encrypt" "auth"];
327 description = ''
328 Cryptographic usage.
329 '';
330 };
331 passPath = lib.mkOption {
332 type = types.str;
333 example = "gnupg/coop/example/contact@";
334 description = ''
335 Password path.
336 '';
337 };
338 subKeys = lib.mkOption {
339 type = types.listOf (types.submodule {
340 options = {
341 algo = lib.mkOption {
342 type = types.enum [ "rsa4096" ];
343 default = "default";
344 example = "rsa4096";
345 description = ''
346 Cryptographic algorithm.
347 '';
348 };
349 expire = lib.mkOption {
350 type = types.str;
351 default = "0";
352 example = "1y";
353 description = ''
354 Expiration timeout.
355 '';
356 };
357 usage = lib.mkOption {
358 type = with types; listOf (enum [ "sign" "encrypt" "auth" "default" ]);
359 default = ["default"];
360 example = ["sign" "encrypt" "auth"];
361 description = ''
362 Cryptographic usage.
363 '';
364 };
365 };
366 });
367 };
368 backupRecipients = lib.mkOption {
369 type = with types; listOf str;
370 default = [];
371 example = ["@john@doe.pro"];
372 description = ''
373 Backup keys used to encrypt the a backup copy of the secret keys.
374 '';
375 };
376 postRun = lib.mkOption {
377 type = types.lines;
378 default = "";
379 description = ''
380 Shell code to run after the key has been generated or tested to exist.
381 '';
382 };
383 };
384 }));
385 };
386 dirmngrConf = lib.mkOption {
387 type = types.lines;
388 apply = s: pkgs.writeText "dirmngr.conf" s;
389 default = ''
390 allow-ocsp
391 hkp-cacert ${gnupg.keyserverPEM}
392 keyserver hkps://keys.mayfirst.org
393 #use-tor
394 #log-file ${gnupg.gnupgHome}/dirmngr.log
395 #standard-resolver
396 '';
397 description = ''
398 GnuPG's dirmngr.conf content.
399 '';
400 };
401 keyserverPEM = lib.mkOption {
402 type = types.lines;
403 apply = s: pkgs.writeText "keyserver.pem" s;
404 default = builtins.readFile gnupg/keyserver.pem;
405 description = ''
406 dirmngr's hkp-cacert content.
407 '';
408 };
409 gpgAgentConf = lib.mkOption {
410 type = types.lines;
411 apply = s: pkgs.writeText "gpg-agent.conf" (s+"\n"+gnupg.gpgAgentExtraConf);
412 default =
413 let pinentry = pkgs.writeShellScript "pinentry" ''
414 #!${pkgs.runtimeShell}
415 # choose pinentry depending on PINENTRY_USER_DATA
416 # this *only works* with gpg2
417 # see https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=802020
418 case "''${PINENTRY_USER_DATA:-curses}" in
419 curses) exec ${pkgs.pinentry.curses}/bin/pinentry-curses "$@";;
420 #emacs) exec ''${pkgs.pinentry.emacs}/bin/pinentry-emacs "$@";;
421 #gnome3) exec ''${pkgs.pinentry.gnome3}/bin/pinentry-gnome3 "$@";;
422 gtk-2) exec ${pkgs.pinentry.gtk2}/bin/pinentry-gtk-2 "$@";;
423 none) exit 1;; # do not ask for passphrase
424 #qt) exec ''${pkgs.pinentry.qt}/bin/pinentry-qt "$@";;
425 tty) exec ${pkgs.pinentry.tty}/bin/pinentry-tty "$@";;
426 esac
427 '';
428 in ''
429 allow-loopback-pinentry
430 allow-preset-passphrase
431 default-cache-ttl 17200
432 default-cache-ttl-ssh 17200
433 enable-ssh-support
434 max-cache-ttl 17200
435 max-cache-ttl-ssh 17200
436 no-allow-external-cache
437 pinentry-program ${pinentry}
438 '';
439 description = ''
440 GnuPG's gpg-agent.conf content.
441 '';
442 };
443 gpgConf = lib.mkOption {
444 type = types.lines;
445 apply = s: pkgs.writeText "gpg.conf" (s+"\n"+gnupg.gpgExtraConf);
446 default = ''
447 auto-key-locate keyserver
448 cert-digest-algo SHA512
449 charset utf-8
450 default-preference-list SHA512 SHA384 SHA256 SHA224 AES256 AES192 AES CAST5 TWOFISH BZIP2 ZLIB ZIP Uncompressed
451 keyid-format 0xlong
452 keyserver-options no-honor-keyserver-url
453 no-auto-key-locate
454 no-default-keyring
455 no-emit-version
456 personal-cipher-preferences AES256 AES CAST5
457 personal-digest-preferences SHA512
458 quiet
459 s2k-cipher-algo AES256
460 s2k-count 65536
461 s2k-digest-algo SHA512
462 s2k-mode 3
463 tofu-default-policy unknown
464 trust-model tofu+pgp
465 use-agent
466 utf8-strings
467 '';
468 description = ''
469 GnuPG's gpg.conf content.
470 '';
471 };
472 gpgExtraConf = lib.mkOption {
473 type = types.lines;
474 default = "";
475 description = ''
476 GnuPG's gpg.conf extra content.
477 '';
478 };
479 gpgAgentExtraConf = lib.mkOption {
480 type = types.lines;
481 default = "";
482 description = ''
483 GnuPG's gpg-agent.conf extra content.
484 '';
485 };
486 };
487 config = lib.mkIf gnupg.enable {
488 nix-shell.buildInputs = [
489 gpg-with-home
490 gpg-fingerprint
491 gpg-keygrip
492 gpg-uid
493 gpg-init
494 ];
495 nix-shell.shellHook = ''
496 # gnupg
497 ${pkgs.coreutils}/bin/install -dm0700 -D ${gnupg.gnupgHome}
498 ${pkgs.coreutils}/bin/ln -snf ${gnupg.gpgConf} ${gnupg.gnupgHome}/gpg.conf
499 ${pkgs.coreutils}/bin/ln -snf ${gnupg.gpgAgentConf} ${gnupg.gnupgHome}/gpg-agent.conf
500 ${pkgs.coreutils}/bin/ln -snf ${gnupg.dirmngrConf} ${gnupg.gnupgHome}/dirmngr.conf
501 export GNUPGHOME=${gnupg.gnupgHome}
502 install -dm700 "$GNUPGHOME"
503 export GPG_TTY=$(${pkgs.coreutils}/bin/tty)
504 ${pkgs.gnupg}/bin/gpgconf --launch gpg-agent
505 export SSH_AUTH_SOCK=$(${pkgs.gnupg}/bin/gpgconf --list-dirs agent-ssh-socket)
506 '';
507 };
508 }