From 99d575a882e00e55871db934901e3817f5daba28 Mon Sep 17 00:00:00 2001 From: pennae Date: Wed, 11 Aug 2021 07:12:26 +0200 Subject: initial commit --- .gitignore | 1 + modules/default.nix | 213 +++++++++++++++++++++++++++++++++++++++++++++ test/simple.nix | 73 ++++++++++++++++ test/simple/aux.key | 3 + test/simple/aux.key.pub | 1 + test/simple/main.key | 3 + test/simple/main.key.pub | 1 + test/simple/secrets.nix | 10 +++ test/simple/store/root | 9 ++ test/simple/store/user/sec | 9 ++ test/simple/user.key | 3 + test/simple/user.key.pub | 1 + util/default.nix | 11 +++ util/seacrit | 123 ++++++++++++++++++++++++++ 14 files changed, 461 insertions(+) create mode 100644 .gitignore create mode 100644 modules/default.nix create mode 100644 test/simple.nix create mode 100644 test/simple/aux.key create mode 100644 test/simple/aux.key.pub create mode 100644 test/simple/main.key create mode 100644 test/simple/main.key.pub create mode 100644 test/simple/secrets.nix create mode 100644 test/simple/store/root create mode 100644 test/simple/store/user/sec create mode 100644 test/simple/user.key create mode 100644 test/simple/user.key.pub create mode 100644 util/default.nix create mode 100755 util/seacrit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..726c3ca --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.*.sw* 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 + . + ''; + 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)} + ''; + }; + }; +} diff --git a/test/simple.nix b/test/simple.nix new file mode 100644 index 0000000..be8c65d --- /dev/null +++ b/test/simple.nix @@ -0,0 +1,73 @@ +{ nixpkgs ? } @ args: + +let + check = lib: config: { + rootSecretExists = + let p = config.seacrit.secrets.root.path; + in lib.stringAfter [ "seacrit-root" ] '' + ( + set -x; + [ "$(cat ${p})" = root ] && \ + [ $(stat -c %u:%g ${p}) = 0:0 ] && \ + [ $(stat -c %a ${p}) = 400 ] && \ + touch /run/root-sec-succeeded + ) + ''; + users.deps = [ "rootSecretExists" ]; + groups.deps = [ "rootSecretExists" ]; + + userSecretExists = + let p = config.seacrit.secrets."user/sec".path; + in lib.stringAfter [ "users" "groups" "seacrit" ] '' + ( + set -x; + [ "$(cat ${p})" = user ] && \ + [ $(stat -c %U:%G ${p}) = user:user ] && \ + [ $(stat -c %a ${p}) = 204 ] && \ + touch /run/user-sec-succeeded + ) + ''; + }; +in +import "${nixpkgs}/nixos/tests/make-test-python.nix" ({ pkgs, ... }: rec { + name = "seacrit-simple"; + + nodes.main = { pkgs, config, lib, ... }: { + imports = [ + ../modules + ]; + + seacrit = { + storePath = ./simple; + hostKeys = [ (pkgs.runCommand "" { key = ./simple/main.key; } "cp $key $out") ]; + + secrets = { + root = { }; + "user/sec" = { owner = "user"; group = "user"; mode = "u=w,o=r"; }; + }; + }; + + users = { + mutableUsers = false; + users.user = { isNormalUser = true; }; + groups.user = {}; + }; + + system.activationScripts = check lib config; + }; + + nodes.other = args@{ pkgs, config, lib, ... }: lib.recursiveUpdate (nodes.main args) { + seacrit.hostID = "main"; + }; + + nodes.aux = args@{ pkgs, config, lib, ... }: lib.recursiveUpdate (nodes.main args) { + seacrit.hostKeys = [ (pkgs.runCommand "" { key = ./simple/aux.key; } "cp $key $out") ]; + }; + + testScript = '' + for m in [ main, other, aux ]: + m.wait_for_unit("multi-user.target") + m.succeed('[ -f /run/root-sec-succeeded ]') + m.succeed('[ -f /run/user-sec-succeeded ]') + ''; +}) args diff --git a/test/simple/aux.key b/test/simple/aux.key new file mode 100644 index 0000000..e969d61 --- /dev/null +++ b/test/simple/aux.key @@ -0,0 +1,3 @@ +# created: 2021-08-11T06:35:48+02:00 +# public key: age1xjtclyph0jfcu0pdmxnmz4yj04hjared5ue4u385whqpl79f2ydqng0x0l +AGE-SECRET-KEY-15FC8DN38VW5HGAQA2M8PKCHQRFC5E0P73QHNFNUAGS4KXF3MP45QHU5QTM diff --git a/test/simple/aux.key.pub b/test/simple/aux.key.pub new file mode 100644 index 0000000..12824d5 --- /dev/null +++ b/test/simple/aux.key.pub @@ -0,0 +1 @@ +age1xjtclyph0jfcu0pdmxnmz4yj04hjared5ue4u385whqpl79f2ydqng0x0l diff --git a/test/simple/main.key b/test/simple/main.key new file mode 100644 index 0000000..9b79384 --- /dev/null +++ b/test/simple/main.key @@ -0,0 +1,3 @@ +# created: 2021-08-08T09:46:38+02:00 +# public key: age1kpyxel2fy7y52rc6n32zwy99gpaersn62f8uejj62vmmymnutdvqx5t258 +AGE-SECRET-KEY-1CAS0QWRWJVDZRUA0JHV6NYHCHDU37DRDNH944PXYF3HV32SQA8CQCZ69Z2 diff --git a/test/simple/main.key.pub b/test/simple/main.key.pub new file mode 100644 index 0000000..ffbc557 --- /dev/null +++ b/test/simple/main.key.pub @@ -0,0 +1 @@ +age1kpyxel2fy7y52rc6n32zwy99gpaersn62f8uejj62vmmymnutdvqx5t258 diff --git a/test/simple/secrets.nix b/test/simple/secrets.nix new file mode 100644 index 0000000..31abf74 --- /dev/null +++ b/test/simple/secrets.nix @@ -0,0 +1,10 @@ +rec { + users.user = "age16w4643wxn796n26ev9dus5a8v3zfzj74uf0vr7cakdpfaz6j2vasvjqvwg"; + hosts.main = "age1kpyxel2fy7y52rc6n32zwy99gpaersn62f8uejj62vmmymnutdvqx5t258"; + hosts.aux = "age1xjtclyph0jfcu0pdmxnmz4yj04hjared5ue4u385whqpl79f2ydqng0x0l"; + default = [ users.user hosts.main ]; + secrets = { + root = [ hosts.aux ]; + "user/sec" = [ hosts.aux ]; + }; +} diff --git a/test/simple/store/root b/test/simple/store/root new file mode 100644 index 0000000..855628e --- /dev/null +++ b/test/simple/store/root @@ -0,0 +1,9 @@ +age-encryption.org/v1 +-> X25519 gGZBkgbY8kOAHrxVYcGeYZP7i/lcyt63+doIL74MbWE +3LOkAvcE4o8Me4XJ1gdwqZJeXgW4fM1DWpDHJuT7Fw4 +-> X25519 KTDtSaC6I3Mp5nXU2/O26U4KXl5PagMVoIT1jGRYCFw +WzB04ZhxAEkLh+UEoyOUCbZ2hIkiwnuA/vdkgNaklIs +-> X25519 ZAHhOjIhElqO3r6XZwrjhUvWLWPoNBUzRM8Ya2zN6GA +94BU/DkUhbw4/S2izZe4dwitJfxDFeyotrBEt23IcJE +--- QRBSOjAFkdB5AN+Y4z+F17MoYSwqcZn1DNWZdXHAYWs +v7¨.Þ6½¥¸–Ðd¨d…±‰7Öïe÷)dö5¯lR´‹ \ No newline at end of file diff --git a/test/simple/store/user/sec b/test/simple/store/user/sec new file mode 100644 index 0000000..e3bdca8 --- /dev/null +++ b/test/simple/store/user/sec @@ -0,0 +1,9 @@ +age-encryption.org/v1 +-> X25519 JLJ+PrrdBKqLi02aXmOh8ijeuGat7QJxO6AGje6fMQE +irKL96OOBHlqP6Vc/eCRUynhqwhnFRQO1xlyP8Pnkfc +-> X25519 OYylx7vnKKpXgWY+38E1RDQ4hjBfDnSqq9HSFIrdJjo +jOYhtLhGn3pwOtExRcJZYw5R3FwxBHNH4ez+lRMPuUE +-> X25519 TVz2Vguw4dC+GVt+Q1dONpSEYVi6Qm8G1GaBdZNExm8 +fZCbL3Z63X6npikm0M87kkaOBhzN05dcXCwTY1FU/e0 +--- GqpfRnIS2I15Gn0ETxkVtR2zb2eBPu7Y33TRr/PWvys +_IçL u­•råÎÓ}bJãqûp+'VƒäQ€¬ðçFô_Ä™` \ No newline at end of file diff --git a/test/simple/user.key b/test/simple/user.key new file mode 100644 index 0000000..e853711 --- /dev/null +++ b/test/simple/user.key @@ -0,0 +1,3 @@ +# created: 2021-08-08T09:45:49+02:00 +# public key: age16w4643wxn796n26ev9dus5a8v3zfzj74uf0vr7cakdpfaz6j2vasvjqvwg +AGE-SECRET-KEY-1702YDJ0KJ9KN9A7ARMMYVXZRZE8M9TTHD5CW22NK5X765W2LAKXQK8QN2A diff --git a/test/simple/user.key.pub b/test/simple/user.key.pub new file mode 100644 index 0000000..245ad08 --- /dev/null +++ b/test/simple/user.key.pub @@ -0,0 +1 @@ +age16w4643wxn796n26ev9dus5a8v3zfzj74uf0vr7cakdpfaz6j2vasvjqvwg diff --git a/util/default.nix b/util/default.nix new file mode 100644 index 0000000..688611b --- /dev/null +++ b/util/default.nix @@ -0,0 +1,11 @@ +{ pkgs ? import {} }: + +pkgs.substituteAll { + pname = "seacrit"; + version = "0.0.1"; + + src = ./seacrit; + dir = "bin"; + isExecutable = true; + perl = pkgs.perl.withPackages (p: with p; [ JSON FileTemp ]); +} diff --git a/util/seacrit b/util/seacrit new file mode 100755 index 0000000..2004e0b --- /dev/null +++ b/util/seacrit @@ -0,0 +1,123 @@ +#!@perl@/bin/perl + +use v5.30; +use strict; +use warnings; +use feature 'signatures'; +no warnings 'experimental::signatures'; +use JSON; +use File::Basename; +use File::Temp; +use File::Path qw( make_path ); + +# we deliberately don't attempt to keep decrypted secrets in mlock/dont-dump +# memory because age doesn't either (yet). + +my $identity; +$identity = "$ENV{HOME}/.seacrit/id" if defined $ENV{HOME}; + +sub encryptTo($ipath, $keys, $opath) { + system("age", "-e", (map { ("-r", $_) } @$keys), "-o", $opath, $ipath) == 0 + or die "age encryption failed"; +} + +sub decrypt($ipath) { + open(my $decfh, "-|", "age", "-d", "-i", $identity, $ipath) or die "decryption failed"; + my $result = do { local $/; <$decfh>; }; + close($decfh) or die "age decryption failed"; + $result; +} + +sub parseSecrets() { + my $script = q{ + { secrets, lib }: + builtins.toJSON (lib.mapAttrs (_: a: a ++ secrets.default or []) secrets.secrets) + } =~ s/'/'\\''/gr; + + my $result = qx{ + nix-instantiate --eval --json --expr '$script' \\ + --arg secrets 'import ./secrets.nix' \\ + --arg lib '(import {}).lib' + }; + + die "failed to evaluate secrets.nix" if $?; + + decode_json(decode_json($result)); +} + +sub cmdEdit($config, $name, $editor = undef) { + my $recipients = $config->{$name}; + die "no such secret" unless defined $recipients; + my $path = "store/$name"; + + $editor = $ENV{EDITOR} unless defined $editor; + die 'no $EDITOR' unless defined $editor; + + -d dirname($path) || make_path(dirname($path)) or die "failed to create store dir"; + + my $tmp = File::Temp->new; + print $tmp decrypt($path) if -e $path; + system($editor, $tmp) == 0 or die "editing secret failed"; + encryptTo($tmp, $recipients, $path); +} + +sub cmdRekey($config) { + for my $name (keys %$config) { + cmdEdit($config, $name, "true") if -e "store/$name"; + } +} + +sub cmdGenkey($path) { + umask 0077; + -d dirname($path) || mkdir(dirname($path)) or die "could not create key dir"; + system("age-keygen", "-o", $path) == 0 or die "key generation failed"; + system("age-keygen", "-y", "-o", "$path.pub", $path) == 0 or die "key generation failed"; +} + +sub usage() { + print <<~EOT; + usage: + seacrit [ -i FILE ] edit + seacrit [ -i FILE ] rekey + seacrit [ -i FILE ] genkey + + General options: + + -h, --help print help + -i, --identity FILE identity to use for decryption + + edit and rekey must be run in the directory of secrets.nix, genkey can be run anywhere. + EOT +} + +sub flop($msg) { + say "error: ", $msg; + say ""; + usage; + exit 1; +} + +while (my $arg = shift @ARGV) { + if ($arg =~ /^-h|--help$/) { + usage; + exit 0; + } elsif ($arg =~ /^-i|--identity$/) { + $identity = shift @ARGV or flop "-i needs an argument"; + } elsif ($arg eq "edit") { + flop "edit needs exactly one argument" unless $#ARGV == 0; + cmdEdit(parseSecrets, $ARGV[0]); + exit 0; + } elsif ($arg eq "rekey") { + flop "too many arguments" unless $#ARGV < 0; + cmdRekey(parseSecrets); + exit 0; + } elsif ($arg eq "genkey") { + flop "too many arguments" unless $#ARGV <= 0; + cmdGenkey($ARGV[0] or $identity); + exit 0; + } else { + flop "unknown argument $arg"; + } +} + +flop "need a command"; -- cgit v1.2.3