feat: add sops
We've previously used SOPS for our secrets management and liked it. We
did, however, find the configuration was a bit annoying to do. In aid of
this, we've made a SOPS module that we find a little easier to make the
sort of configurations we want without creating so much mess.
We haven't set up scalpel/equivalent yet - we intend to avoid it if at
all possible. It isn't necessarily out-of-scope but it isn't included in
our current SOPS plans.
Change-Id: I35b9c7e94c12a4f1360833026efe06803d59626e
Reviewed-on: https://git.clicks.codes/c/Infra/NixFiles/+/725
Reviewed-by: Samuel Shuert <coded@clicks.codes>
Tested-by: Samuel Shuert <coded@clicks.codes>
diff --git a/.envrc b/.envrc
index 9039a81..4c7d988 100644
--- a/.envrc
+++ b/.envrc
@@ -8,3 +8,5 @@
fi
use flake .#
+
+./configure.sh
diff --git a/.gitignore b/.gitignore
index e63526b..ee056bc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,4 @@
result
.direnv
+.sops.yaml
diff --git a/.sops.nix b/.sops.nix
new file mode 100644
index 0000000..78271c5
--- /dev/null
+++ b/.sops.nix
@@ -0,0 +1,63 @@
+# SPDX-FileCopyrightText: 2024 Clicks Codes
+#
+# SPDX-License-Identifier: GPL-3.0-only
+
+nixpkgs:
+let
+ keys = {
+ users = {
+ coded = "BC82DF237610AE9113EB075900E944BFBE99ADB5";
+ minion = "76E0B09A741C4089522111E5F27E3E5922772E7A";
+ pinea = "8F50789F12AC6E6206EA870CE5E1C2D43B0E4AB3";
+ };
+ hosts = {
+ # nix run github:Mic92/ssh-to-pgp -- -i /etc/ssh/ssh_host_rsa_key
+ a1d1 = "67c66d58ac73fd744c2b49720f026aad93752d6a";
+ };
+ };
+in
+{
+ creation_rules = [
+ {
+ path_regex = ''.*\/a1d1\/.*\.sops\.(yaml|json|env|ini|([^.]\.)*bin)$'';
+ pgp = nixpkgs.lib.concatStringsSep "," [
+ keys.users.coded
+ keys.users.minion
+ keys.users.pinea
+
+ keys.hosts.a1d1
+ ];
+ }
+ ];
+}
+
+/* A1D1
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+xsFNBAAAAAABEACSxCiPC32/kuhkaXnxLcXWQuNkKb3oimnzVn2cOl6X7mpwUQkO
+WSL4mP+s/bsEoHuC17h+IbuA3vm62fWhfxoC59sJe3J0zNUb9YzHu2RkyO23msoo
+WBbO+3qCs8W+/1FIh5LTW5X35V5Bl3D2p/4Xydk3qKvyU3VQp8JYJZahP2Rwxs8g
+2IGWV39dJVwwBL/3ZRY122jBc0m1TKXVtg1pzkpJoNLQNWVPH3xrRjhAplXY8ArF
+MT1trQHvTNC3fIxAlc+ED8Mf9nzYikxyQQmvwR98cE20Nzlrs8VSw+Xwo3v6/t0j
+hmlUQTtDJMl1Oow3VLUZwvsHcSc+JuZW24t/1i1iZ59fi5/ZlbXQGgJ/Iwrx/3n0
+3grQufiWAsN3ALHkyD0KFjxqlt9M8DSg8OYMzPvRK/75vPPB1oaXXG76Us9bkF/M
+vckCpHoxBEGu/eSY2MBcW7CrWXkLW898txJfhgh6o2TQjPWcnGCDn+tGA9AxvGl1
+HlnyVz+MIJvQ2Pp9DGMEqSPNWiv1ESPAgiyeIuDAL9pnpLO+WFfc/NU2GUnPybEk
+vzq8uYiD0Nyr01ruxdcsmagbI/7z8h93bNMpo8V7/nT8n881oJYUtTWrJ+CTB6F/
+9ulZteFbXBQ5i2Xk+VYeVjVZ2snkCZ16qm4j81PFojRm1NUbRmz5uoYFwQARAQAB
+zSlyb290IChJbXBvcnRlZCBmcm9tIFNTSCkgPHJvb3RAbG9jYWxob3N0PsLBYgQT
+AQgAFgUCAAAAAAkQDwJqrZN1LWoCGw8CGQEAAC2GEAAK0ceEOyeb2YlhCN750G2s
+H+bGWlV+AyEDAocPEQJxxG3WJVMldXXaeZnFJ7bbILouMVBNcaGzWBHy8vuDGz3T
+GmjHRmscN5ZMA5to5khf42q+fd5XvBRgdgED5RKIqNlNT60VODqPe/sVtwOV0p2R
+3Mmk3ycnsJuOfmvxP3JCHCWDCeVlT/THN5qpZlAqBK12GUQBgpalUqTl/gfMR00E
+eSI/KEch1vZaj+hQr4Hmu+2tz+0K9Vjhr+esDWIbCLYUJ+pjLCcEY9V1KzSA/mgo
+lvdIXOKTDDvUw12LU2vZkvQBskjfQw65M9mnw8n95Y4QnynW0qzMXT5XE01WYi6q
+PdJCfJKllJ+2TXt8XlqcM/wQvJMJB+PDdbfC5Z468WBBrZdjkqFpJnVT4j77zTlK
+X6/3OHqVdD5bEPceIrG/Iefcy3LNYF38euR1QOCzpOywyMlaujYXQdJbBPngkXAc
+GjYO3gevAkfaltLWddX5cK0YzrRI5m8e0zCLVGbcqxU7vK5ZmJKwTJ8W7INQrH3h
+IDtqRQ8k0eRIv8mXF1sFgyFiPmyyJdYqaosR+hxi9nerAChk7TLTNN7fnoUirowN
+unr5YcMBKpjiT6VMeYLtVsLcpwjSqet/d+/+yHy+Yn6As67IV67c2+tkZAHk5N4I
+vs8VtLQNyjiNH4Rbc8c1RQ==
+=A4oI
+-----END PGP PUBLIC KEY BLOCK-----
+*/
diff --git a/configure.sh b/configure.sh
new file mode 100755
index 0000000..7ff1090
--- /dev/null
+++ b/configure.sh
@@ -0,0 +1,7 @@
+#!/bin/sh
+
+# SPDX-FileCopyrightText: 2024 Clicks Codes
+#
+# SPDX-License-Identifier: GPL-3.0-only
+
+nix eval -f .sops.nix --apply "(f: f (builtins.getFlake \"nixpkgs\"))" --json > .sops.yaml # regenerate the "yaml" so you can add secrets
diff --git a/flake.lock b/flake.lock
index 0289d4f..71d0a65 100644
--- a/flake.lock
+++ b/flake.lock
@@ -127,6 +127,7 @@
"impermanence": "impermanence",
"nixpkgs": "nixpkgs",
"snowfall-lib": "snowfall-lib",
+ "sops-nix": "sops-nix",
"unstable": "unstable"
}
},
@@ -153,6 +154,29 @@
"type": "github"
}
},
+ "sops-nix": {
+ "inputs": {
+ "nixpkgs": [
+ "unstable"
+ ],
+ "nixpkgs-stable": [
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1717455931,
+ "narHash": "sha256-8Q6mKSsto8gaGczXd4G0lvawdAYLa5Dlh3/g4hl5CaM=",
+ "owner": "Mic92",
+ "repo": "sops-nix",
+ "rev": "d4555e80d80d2fa77f0a44201ca299f9602492a0",
+ "type": "github"
+ },
+ "original": {
+ "owner": "Mic92",
+ "repo": "sops-nix",
+ "type": "github"
+ }
+ },
"systems": {
"locked": {
"lastModified": 1681028828,
diff --git a/flake.nix b/flake.nix
index a565974..25086f4 100644
--- a/flake.nix
+++ b/flake.nix
@@ -20,6 +20,14 @@
inputs.nixpkgs.follows = "nixpkgs";
};
+ sops-nix = {
+ url = "github:Mic92/sops-nix";
+ inputs = {
+ nixpkgs.follows = "unstable";
+ nixpkgs-stable.follows = "nixpkgs";
+ };
+ };
+
impermanence.url = "github:nix-community/impermanence";
};
@@ -39,7 +47,10 @@
lib.mkFlake {
overlays = with inputs; [ ];
- systems.modules.nixos = [ inputs.impermanence.nixosModules.impermanence ];
+ systems.modules.nixos = [
+ inputs.impermanence.nixosModules.impermanence
+ inputs.sops-nix.nixosModules.sops
+ ];
deploy = lib.mkDeploy {
inherit (inputs) self;
diff --git a/lib/secrets/default.nix b/lib/secrets/default.nix
new file mode 100644
index 0000000..c8cf609
--- /dev/null
+++ b/lib/secrets/default.nix
@@ -0,0 +1,8 @@
+# SPDX-FileCopyrightText: 2024 Clicks Codes
+#
+# SPDX-License-Identifier: GPL-3.0-only
+
+{ lib, inputs, ... }:
+{
+ secrets.name = path: builtins.hashFile "sha256" path;
+}
diff --git a/lib/strings/default.nix b/lib/strings/default.nix
new file mode 100644
index 0000000..dccb09f
--- /dev/null
+++ b/lib/strings/default.nix
@@ -0,0 +1,15 @@
+# SPDX-FileCopyrightText: 2024 Clicks Codes
+#
+# SPDX-License-Identifier: GPL-3.0-only
+
+{ lib, inputs, ... }:
+{
+ strings = {
+ getCommonPrefix =
+ a: b:
+ let
+ prefixLength = lib.strings.commonPrefixLength a b;
+ in
+ builtins.substring 0 prefixLength a;
+ };
+}
diff --git a/modules/nixos/clicks/secrets/README.md b/modules/nixos/clicks/secrets/README.md
new file mode 100644
index 0000000..83f43b9
--- /dev/null
+++ b/modules/nixos/clicks/secrets/README.md
@@ -0,0 +1,62 @@
+<!--
+SPDX-FileCopyrightText: 2024 Clicks Codes
+
+SPDX-License-Identifier: GPL-3.0-only
+-->
+
+# Clicks SOPS
+
+To create a secret you can do the following:
+
+```nix
+clicks.secrets."${lib.clicks.secrets.name ./headscale.sops.json}" = {
+ file = ./headscale.sops.json;
+ group = "headscale";
+ keys = [
+ "oidc_client_secret"
+ "database_password"
+ "noise_private_key"
+ "private_key"
+ ];
+ neededForUsers = false;
+};
+```
+The secret name is based on the secret file's hash.
+`file` is a path to the secrets file. It is required.
+`group` is the group the key should be owned by. We chose to use groups instead of users so that you can allow multiple
+different users to read the file. If you don't set it, we'll use `"root`.
+`keys` is a list of the keys of the secret file, assuming it's not a binary file. If it isn't a binary file, you are
+required to set this. If it is a binary file, you shouldn't specify this.
+`neededForUsers` requires the secret to be present before users are created on boot, it's identical to the sops option
+of the same name. Use it for user passwords. If you don't specify it, we'll use `false`.
+
+---
+
+You can then refer to the different keys directly from the secret, no need to manually create individual files:
+
+```nix
+client_secret_path = config.clicks.secrets."${lib.clicks.secrets.name ./headscale.sops.json}".paths.oidc_client_secret;
+```
+
+If the secret file is a binary file, the path can be accessed via
+
+```nix
+private_key = config.clicks.secrets."${lib.clicks.secrets.name ./privatekey.bin}".path;
+```
+
+---
+
+We recommend using `lib.clicks.secrets.name` with your path to name your secrets. This avoids you creating naming
+conflicts or having messy names. This is not a hard requirement for using the module outside of Clicks, but if you're
+contributing to Clicks infrastructure we will enforce this at review.
+
+This takes a path, and is guarenteed to be stable when passed the same file at the same path.
+
+```nix
+lib.clicks.secrets.name ./file.sops.json
+```
+
+---
+
+In Clicks, secrets are only ever encrypted to a single host. You'll need to make the secrets within the
+`systems/<arch>/<hostname>` directory to let sops know what host to encrypt to.
diff --git a/modules/nixos/clicks/secrets/default.nix b/modules/nixos/clicks/secrets/default.nix
new file mode 100644
index 0000000..19f01b4
--- /dev/null
+++ b/modules/nixos/clicks/secrets/default.nix
@@ -0,0 +1,138 @@
+# SPDX-FileCopyrightText: 2024 Clicks Codes
+#
+# SPDX-License-Identifier: GPL-3.0-only
+
+{
+ lib,
+ pkgs,
+ config,
+ ...
+}:
+let
+ cfg = config.clicks.security.sops;
+
+ guessFormat =
+ extension:
+ if extension == "json" then
+ "json"
+ else if extension == "yaml" || extension == "yml" then
+ "yaml"
+ else if extension == "env" then
+ "dotenv"
+ else if extension == "ini" then
+ "ini"
+ else
+ "binary";
+
+ getExtension =
+ filePath:
+ let
+ pathParts = builtins.split ''\.'' (builtins.toString filePath);
+ numPathParts = builtins.length pathParts;
+ in
+ builtins.elemAt pathParts (numPathParts - 1);
+in
+{
+ options.clicks.secrets =
+ let
+ generateNonBinarySopsPaths =
+ file: keys:
+ lib.lists.forEach keys (key: {
+ name = key;
+ value = config.sops.secrets."${lib.clicks.secrets.name file}:${key}".path;
+ });
+ in
+ lib.mkOption {
+ type = lib.types.attrsOf (
+ lib.types.submodule (
+ { ... }@submodule:
+ {
+ options = {
+ file = lib.mkOption {
+ type = lib.types.pathInStore;
+ description = "The store path to your secrets file";
+ };
+ group = lib.mkOption {
+ type = lib.types.str;
+ description = "The user the secret should be owned by.";
+ default = "root";
+ };
+ keys = lib.mkOption {
+ type = lib.types.nullOr (lib.types.listOf lib.types.str);
+ description = "List of keys to pull from the structured data.";
+ default = null;
+ };
+ neededForUsers = lib.mkEnableOption "This secret is needed for users";
+ paths = lib.mkOption {
+ type = lib.types.nullOr (lib.types.attrsOf lib.types.str);
+ description = "Automatically populated with the SOPS paths to your keys, null if you are using binary secrets";
+ default =
+ if guessFormat (getExtension submodule.config.file) != "binary" then
+ builtins.listToAttrs (generateNonBinarySopsPaths submodule.config.file submodule.config.keys)
+ else
+ null;
+ };
+ path = lib.mkOption {
+ type = lib.types.nullOr lib.types.str;
+ description = "Populated automatically with the SOPS path of the secret, null if you are using non binary secrets";
+ default =
+ if guessFormat (getExtension submodule.config.file) == "binary" then
+ config.sops.secrets.${lib.clicks.secrets.name submodule.config.file}.path
+ else
+ null;
+ };
+ };
+ }
+ )
+ );
+ description = "";
+ default = { };
+ };
+
+ config =
+ let
+ generateBinarySopsSecret = secret: {
+ name = lib.clicks.secrets.name secret.value.file;
+ value = {
+ mode = "0400";
+ owner = config.users.users.root.name;
+ group = config.users.groups.${secret.value.group}.name;
+ sopsFile = secret.value.file;
+ format = guessFormat (getExtension secret.value.file);
+ inherit (secret.value) neededForUsers;
+ };
+ };
+
+ generateNonBinarySopsSecrets =
+ secret:
+ lib.lists.forEach secret.value.keys (key: {
+ name = "${lib.clicks.secrets.name secret.value.file}:${key}";
+ value = {
+ mode = "0040";
+ owner = config.users.users.root.name;
+ group = config.users.groups.${secret.value.group}.name;
+ sopsFile = secret.value.file;
+ format = guessFormat (getExtension secret.value.file);
+ inherit (secret.value) neededForUsers;
+ inherit key;
+ };
+ });
+
+ secretsAsList = lib.attrsets.attrsToList config.clicks.secrets;
+
+ secretsAsSops = lib.pipe secretsAsList [
+ (map (
+ secret:
+ if guessFormat (getExtension secret.value.file) == "binary" then
+ generateBinarySopsSecret secret
+ else
+ generateNonBinarySopsSecrets secret
+ ))
+ lib.flatten
+ builtins.listToAttrs
+ ];
+ in
+ {
+ sops.secrets = secretsAsSops;
+ };
+}