summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorpennae <pennae.git@quasiparticle.net>2021-08-11 07:12:26 +0200
committerpennae <pennae.git@quasiparticle.net>2021-08-11 07:17:43 +0200
commit99d575a882e00e55871db934901e3817f5daba28 (patch)
tree76f4b397d0719659d8ebd6ded310bc87875afbc0
downloadseacrit-99d575a882e00e55871db934901e3817f5daba28.tar.gz
seacrit-99d575a882e00e55871db934901e3817f5daba28.tar.xz
seacrit-99d575a882e00e55871db934901e3817f5daba28.zip
initial commit
-rw-r--r--.gitignore1
-rw-r--r--modules/default.nix213
-rw-r--r--test/simple.nix73
-rw-r--r--test/simple/aux.key3
-rw-r--r--test/simple/aux.key.pub1
-rw-r--r--test/simple/main.key3
-rw-r--r--test/simple/main.key.pub1
-rw-r--r--test/simple/secrets.nix10
-rw-r--r--test/simple/store/root9
-rw-r--r--test/simple/store/user/sec9
-rw-r--r--test/simple/user.key3
-rw-r--r--test/simple/user.key.pub1
-rw-r--r--util/default.nix11
-rwxr-xr-xutil/seacrit123
14 files changed, 461 insertions, 0 deletions
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
+ <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 = 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 ? <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 <nixpkgs> {} }:
+
+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 <nixpkgs> {}).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 <secret-name>
+ seacrit [ -i FILE ] rekey
+ seacrit [ -i FILE ] genkey <key-path>
+
+ 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";