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;
+    };
+}