Commit 40d308739f88

Vincent Demeester <vincent@sbr.pm>
2026-06-30 11:19:50
feat(ssh): gate keys via keyed registry and praetorian
Convert globals.ssh.vincent from a flat key list to a keyed registry where every entry declares per-account, per-host access (trusted, gated, or absent), rendered by libx.authorizedKeysFor. This makes the unrestricted surface greppable and structurally prevents a key from being silently over-provisioned. Gate the aomi TPM key to git push+pull, the passage sync keys to a pull-only passage alias, and the termux phones to pull-only on repos under ~/git, all scoped to carthage. Root now receives only fully trusted keys, removing aomi-tpm-as-root from every host. Enable services.praetorian on carthage with the aomi-git, git-pull and passage-pull aliases backing those command= restrictions.
1 parent edb48c5
lib/default.nix
@@ -109,6 +109,7 @@
         self.nixosModules.govanityurl
         self.nixosModules.gosmee
         self.nixosModules.rsync-replica
+        self.nixosModules.praetorian
         agenixInput.nixosModules.default
         inputs.disko.nixosModules.disko
         inputs.lanzaboote.nixosModules.lanzaboote
lib/functions.nix
@@ -331,6 +331,55 @@ let
       inherit user group openFirewall;
     };
 
+  /**
+    Render an authorized_keys list for a given host + account from the keyed SSH
+    registry (see globals.ssh.<user>).
+
+    Each registry entry is `{ key; access; }` where `access.<account>.<host>` (or
+    `access.<account>.default`) resolves to one of:
+      - "trusted"        -> bare key (full, unrestricted login)
+      - { gated = "a"; } -> key prefixed with command="praetorian run a" + lockdown
+      - absent           -> key omitted for that host/account
+    `access.<account>` may also be the string "trusted"/"absent" as a whole-account
+    shorthand for `.default`.
+
+    Every entry MUST declare a non-empty `access`; missing/invalid shapes throw at
+    eval time so an over-provisioned key can never silently grant access.
+
+    @param registry The keyed SSH registry (attrset label -> { key; access; })
+    @param host The current hostname
+    @param account The account being rendered ("vincent", "root", ...)
+    @return List of authorized_keys lines
+  */
+  authorizedKeysFor =
+    registry: host: account:
+    let
+      gateOpts = "no-pty,no-agent-forwarding,no-port-forwarding,no-X11-forwarding,no-user-rc";
+      resolveLeaf =
+        label: leaf: key:
+        if leaf == null || leaf == "absent" then
+          null
+        else if leaf == "trusted" then
+          key
+        else if builtins.isAttrs leaf && leaf ? gated then
+          ''command="praetorian run ${leaf.gated}",${gateOpts} ${key}''
+        else
+          throw ''ssh key '${label}': invalid access leaf for ${account}@${host} (expected "trusted", { gated = "alias"; }, or "absent")'';
+      resolveOne =
+        label: entry:
+        let
+          access =
+            if entry ? access && entry.access != { } then
+              entry.access
+            else
+              throw "ssh key '${label}': missing non-empty 'access'";
+          ua = access.${account} or null;
+          leaf = if builtins.isString ua then ua else (if ua == null then null else (ua.${host} or ua.default or null));
+        in
+        if ua == null then null else resolveLeaf label leaf entry.key;
+    in
+    builtins.filter (x: x != null) (lib.attrValues (lib.mapAttrs resolveOne registry));
+
   /**
     Create a Samba share configuration with common defaults.
 
@@ -389,5 +438,6 @@ in
     sshConfigs
     mkServiceDefaults
     mkSambaShare
+    authorizedKeysFor
     ;
 }
systems/carthage/extra.nix
@@ -123,6 +123,53 @@ in
     ../common/services/openssh.nix
   ];
 
+  # ── Praetorian SSH command gating ───────────────────────────────────
+  # Gates command-restricted keys (see globals.ssh.vincent) to git over SSH.
+  # authorized_keys entries use command="praetorian run <alias>"; these aliases
+  # define what each is allowed to run. Repos live under /home/vincent/git.
+  services.praetorian = {
+    enable = true;
+    aliases = {
+      # aomi TPM key: full git push + pull on any repo under ~/git.
+      aomi-git.allow = {
+        "git-receive-pack" = {
+          arg = {
+            pos = 1;
+            glob = "/home/vincent/git/*";
+          };
+          num_args = 1;
+        };
+        "git-upload-pack" = {
+          arg = {
+            pos = 1;
+            glob = "/home/vincent/git/*";
+          };
+          num_args = 1;
+        };
+      };
+      # Termux phones: pull-only on any repo under ~/git (src/home, passage, ...).
+      git-pull.allow = {
+        "git-upload-pack" = {
+          arg = {
+            pos = 1;
+            glob = "/home/vincent/git/*";
+          };
+          num_args = 1;
+        };
+      };
+      # Passage sync keys: pull-only on the passage store repo.
+      passage-pull.allow = {
+        "git-upload-pack" = {
+          arg = {
+            pos = 1;
+            glob = "/home/vincent/git/passage.git";
+          };
+          num_args = 1;
+        };
+      };
+    };
+  };
+
   # ── Fail2ban ────────────────────────────────────────────────────────
   services.fail2ban = {
     enable = true;
systems/common/users/root.nix
@@ -1,9 +1,13 @@
 {
   globals,
+  hostname,
+  libx,
   ...
 }:
 {
   users.users.root = {
-    openssh.authorizedKeys.keys = globals.ssh.vincent ++ [ globals.machines.shikoku.ssh.vincent ];
+    openssh.authorizedKeys.keys =
+      libx.authorizedKeysFor globals.ssh.vincent hostname "root"
+      ++ [ globals.machines.shikoku.ssh.vincent ];
   };
 }
systems/common/users/vincent.nix
@@ -57,7 +57,7 @@ in
     initialPassword = "changeMe";
 
     # FIXME set this up better
-    openssh.authorizedKeys.keys = globals.ssh.vincent;
+    openssh.authorizedKeys.keys = libx.authorizedKeysFor globals.ssh.vincent hostname "vincent";
 
     # 🤔
     packages = [ pkgs.home-manager ];
globals.nix
@@ -1,28 +1,113 @@
 _: {
   ssh = {
-    vincent = [
+    # Keyed SSH registry. Each entry is `{ key; access; }` where
+    # `access.<account>.<host|default>` resolves to one of:
+    #   "trusted"        -> full unrestricted login
+    #   { gated = "a"; } -> command="praetorian run a" restricted (see carthage)
+    #   absent           -> no access on that host/account
+    # Rendered via libx.authorizedKeysFor. Every entry MUST declare a non-empty
+    # `access` (eval-time throw otherwise) so a key can never be silently
+    # over-provisioned. Greppable unrestricted surface: `grep '"trusted"'`.
+    vincent = {
       # Yubikeys (PIV - legacy, keep during transition)
-      "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFT5Rx+4Wuvd8lMBkcHxb4oHdRhm/OTg+p5tvPzoIN9enSmgRw5Inm/SlS8ZzV87G1NESTgzDRi6hREvqDlKvxs="
-      "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBGHMa4rHuBbQQYv+8jvlkFCD2VYRGA4+5fnZAhLx8iDirzfEPqHB60UJWcDeixnJCUlpJjzFbS4crNOXhfCTCTE="
-      "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBBFzxC16VqwTgWDQfw2YCiOw2JzpH3z9XgHtKoHhBdHi2i9m9XUc7fIUeEIIf7P8ARRNd8q5bjvl8JY7LtPkNCU="
-      # FIDO2 resident keys (homelab)
-      "sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIODTc5Exm59skgJdu6/rA3CpX4k4P1CFBqCFtelWGGmEAAAAC3NzaDpob21lbGFi homelab-servers"
-      "sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIAGh5p44LvQrWjAMyC/5LjUnViqFl3ddVfiFnoiLgJb7AAAAEnNzaDpjcml0aWNhbC1pbmZyYQ== infra-touch-required"
-      # FIDO2 resident keys (okinawa)
-      "sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIEefW7gStvkrO98v6UUawwa3yOu896Ei8USE/Sh2DjaUAAAABHNzaDo= vincent@okinawa"
-      # Passage sync keys (non-SK, for automated git pull)
-      "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBqhe3iS2058Ro8jN0b5Sr1tb+fEyqwgEmEC7vCM0za4 vincent@kyushu-passage"
-      "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAII/CgISZ5XVyg8eYH7b56EWo4UGplOzzZKdtMKtkZqxc vincent@okinawa-passage"
-      # Host keys (trusted machines)
+      # TODO: phase these out once FIDO2/TPM coverage is complete.
+      yubikey-piv-1 = {
+        key = "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFT5Rx+4Wuvd8lMBkcHxb4oHdRhm/OTg+p5tvPzoIN9enSmgRw5Inm/SlS8ZzV87G1NESTgzDRi6hREvqDlKvxs=";
+        access = {
+          vincent.default = "trusted";
+          root.default = "trusted";
+        };
+      };
+      yubikey-piv-2 = {
+        key = "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBGHMa4rHuBbQQYv+8jvlkFCD2VYRGA4+5fnZAhLx8iDirzfEPqHB60UJWcDeixnJCUlpJjzFbS4crNOXhfCTCTE=";
+        access = {
+          vincent.default = "trusted";
+          root.default = "trusted";
+        };
+      };
+      yubikey-piv-3 = {
+        key = "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBBFzxC16VqwTgWDQfw2YCiOw2JzpH3z9XgHtKoHhBdHi2i9m9XUc7fIUeEIIf7P8ARRNd8q5bjvl8JY7LtPkNCU=";
+        access = {
+          vincent.default = "trusted";
+          root.default = "trusted";
+        };
+      };
+      # FIDO2 resident keys (touch is the gate -> unrestricted)
+      homelab-servers = {
+        key = "sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIODTc5Exm59skgJdu6/rA3CpX4k4P1CFBqCFtelWGGmEAAAAC3NzaDpob21lbGFi homelab-servers";
+        access = {
+          vincent.default = "trusted";
+          root.default = "trusted";
+        };
+      };
+      critical-infra = {
+        key = "sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIAGh5p44LvQrWjAMyC/5LjUnViqFl3ddVfiFnoiLgJb7AAAAEnNzaDpjcml0aWNhbC1pbmZyYQ== infra-touch-required";
+        access = {
+          vincent.default = "trusted";
+          root.default = "trusted";
+        };
+      };
+      okinawa-fido = {
+        key = "sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIEefW7gStvkrO98v6UUawwa3yOu896Ei8USE/Sh2DjaUAAAABHNzaDo= vincent@okinawa";
+        access = {
+          vincent.default = "trusted";
+          root.default = "trusted";
+        };
+      };
+      # Passage sync keys (non-SK, automated git pull only)
+      # TODO: replace with the per-machine TPM approach.
+      kyushu-passage = {
+        key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBqhe3iS2058Ro8jN0b5Sr1tb+fEyqwgEmEC7vCM0za4 vincent@kyushu-passage";
+        access.vincent.carthage = {
+          gated = "passage-pull";
+        };
+      };
+      okinawa-passage = {
+        key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAII/CgISZ5XVyg8eYH7b56EWo4UGplOzzZKdtMKtkZqxc vincent@okinawa-passage";
+        access.vincent.carthage = {
+          gated = "passage-pull";
+        };
+      };
       # aomi user key: TPM-sealed ecdsa via ssh-tpm-agent (machine-bound, no touch)
-      "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBGEL1A++UUsFnGmnrIR8S6A8IUFkR5WyxDPol2bs02TY0O+sWOPJFJZIAKqcWVSUIkxRoktUBMRe0mMZyiHa3jw= vdemeest@aomi-tpm"
-      "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIE3iD9Eaf5xglTyP+kIO9t8qQpF2H42rQ2AuUibs2hn7 vincent@aion"
-      "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGThdcaPfIaB7d+K5uODqEusLKGI5ZCye0aNOCaMoInO Kyushu's ssh key"
-      # Boox (osaka) Termux
-      "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICa0SyAspL7PBPudCjb7oCBG17WRmYnDQF7/BYkFwqDi oksaka-termux"
-      # Light Phone (suzu) Termux
-      "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK0Q1oXzMMJG03n2VoxKKOGruyGBy8V8yanqgeeUYm+N suzu-termux"
-    ];
+      # Gated to git push+pull on carthage only.
+      aomi-tpm = {
+        key = "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBGEL1A++UUsFnGmnrIR8S6A8IUFkR5WyxDPol2bs02TY0O+sWOPJFJZIAKqcWVSUIkxRoktUBMRe0mMZyiHa3jw= vdemeest@aomi-tpm";
+        access.vincent.carthage = {
+          gated = "aomi-git";
+        };
+      };
+      # aion host key: has automation (restic backup to aix).
+      # TODO: scope to aix + gate restic instead of full trust everywhere.
+      aion-hostkey = {
+        key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIE3iD9Eaf5xglTyP+kIO9t8qQpF2H42rQ2AuUibs2hn7 vincent@aion";
+        access = {
+          vincent.default = "trusted";
+          root.default = "trusted";
+        };
+      };
+      # kyushu host key.
+      # TODO: work on removing this key entirely.
+      kyushu-hostkey = {
+        key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGThdcaPfIaB7d+K5uODqEusLKGI5ZCye0aNOCaMoInO Kyushu's ssh key";
+        access = {
+          vincent.default = "trusted";
+          root.default = "trusted";
+        };
+      };
+      # Termux phones: git pull only (src/home, passage, ... under ~/git).
+      osaka-termux = {
+        key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICa0SyAspL7PBPudCjb7oCBG17WRmYnDQF7/BYkFwqDi oksaka-termux";
+        access.vincent.carthage = {
+          gated = "git-pull";
+        };
+      };
+      suzu-termux = {
+        key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK0Q1oXzMMJG03n2VoxKKOGruyGBy8V8yanqgeeUYm+N suzu-termux";
+        access.vincent.carthage = {
+          gated = "git-pull";
+        };
+      };
+    };
   };
   syncthingFolders = {
     sync = {