Operator Keys and Groups

Updated

September 12, 2026

Operator Keys and Groups

NixOS/infra keeps every operator and machine pubkey in one keys.nix: users, groups (infra-core, infra, ofborg), machine host keys, age recipients. The boring default is: the same file for the desk — public keys only — and users.mutableUsers = false so a leftover authorized_keys on disk cannot add a human.

Do not copy their key strings. Copy the attrset.

Mental model

keys.nix
  ssh.users.alice    = [ "ssh-ed25519 AAAA… alice@laptop" ]
  ssh.groups.desk-core = alice ++ bob
  ssh.machines.gw-01 = "ssh-ed25519 AAAA…"   # host key, for knownHosts
  age.recipients.alice = [ "age1…" ]
        │
        ▼
users.users.root.openssh.authorizedKeys.keys = keys.ssh.groups.desk-core;

Groups are concatenations of user lists. Adding alice to desk-core is one line; every host that imports the group gets her key on the next switch. Removing her is the same line, plus a rebuild — not sed on five boxes.

mutableUsers = false means NixOS owns /etc/passwd and authorized keys. A useradd on the box dies at next activation.

Age groups in their file mix SSH groups with YubiKey age1yubikey1… recipients. Desk: sops .sops.yaml creation rules point at the same people. One list of humans.

Worked examples

Case 1: Desk keys.nix

Save as keys.nix:

# keys.nix
rec {
  ssh = {
    users = {
      alice = [
        "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDeskAliceExampleKeyOnlyNotReal alice@laptop"
      ];
      bob = [
        "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDeskBobExampleKeyOnlyNotReal bob@laptop"
      ];
      hydra-queue = [
        "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDeskHydraExampleKeyOnlyNotReal hydra-queue@ops-01"
      ];
    };

    groups = with ssh.users; {
      desk-core = alice ++ bob;
      desk-builders = desk-core;
      desk-ci = desk-core ++ hydra-queue;
    };

    machines = {
      gw-01 = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDeskGw01HostKeyExampleOnly";
      app-01 = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDeskApp01HostKeyExampleOnly";
      ops-01 = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDeskOps01HostKeyExampleOnly";
    };
  };

  age = {
    recipients = {
      alice = [ "age1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq2w4k7f" ];
    };
    groups = {
      desk-core = ssh.groups.desk-core;
    };
  };
}

Replace every AAAA… / age1… with your pubkeys (ssh-keygen -y, ssh-to-age < ~/.ssh/id_ed25519.pub). The strings above are placeholders; they must not be committed as if they opened a box.

nix eval --file keys.nix 'ssh.groups.desk-core' --apply builtins.length
2

Case 2: Root and a named admin from the group

Save as modules/users-from-keys.nix:

# modules/users-from-keys.nix
{ pkgs, ... }:

let
  keys = import ../keys.nix;
in
{
  users.mutableUsers = false;

  users.users.root.openssh.authorizedKeys.keys = keys.ssh.groups.desk-core;

  users.users.deskadmin = {
    isNormalUser = true;
    extraGroups = [ "wheel" ];
    openssh.authorizedKeys.keys = keys.ssh.groups.desk-core;
  };

  security.sudo.wheelNeedsPassword = false; # lab; prod: password or SSH-only + sudoers
  services.openssh = {
    enable = true;
    settings = {
      PasswordAuthentication = false;
      KbdInteractiveAuthentication = false;
      PermitRootLogin = "prohibit-password";
    };
  };
}

NixOS/infra uses users.extraUsers.root (legacy alias). On 26.05 write users.users.root. Same option.

nix eval .#nixosConfigurations.ops-01.config.users.users.root.openssh.authorizedKeys.keys --json | jq 'length'

Must equal the length of desk-core.

Case 3: knownHosts from ssh.machines

Save as modules/known-hosts.nix:

# modules/known-hosts.nix
{
  programs.ssh.knownHosts = let
    keys = import ../keys.nix;
  in
    builtins.mapAttrs (_name: publicKey: { inherit publicKey; }) keys.ssh.machines;
}

Colmena/SSH from ops-01 then does not prompt on first connect. Rotate a host key → change keys.nix → switch ops-01. Leaving StrictHostKeyChecking=no in Colmena is how you accept a MITM once.

Host keys on ephemeral roots must be persisted (/etc/ssh/ssh_host_ed25519_key) or this file and the metal disagree every boot.

Case 4: Drop a human in one commit

# keys.nix fragment after alice leaves
groups = with ssh.users; {
  desk-core = bob;          # alice removed
  desk-ci = bob ++ hydra-queue;
};
nix eval --file keys.nix 'ssh.groups.desk-core' --apply 'xs: builtins.any (k: builtins.match ".*alice.*" k != null) xs'
false

Then colmena apply --on @core. There is no “disable the account on the bastion wiki.” The next generation has no alice key. Rotate anything she could have copied (sops recipients, age, Cachix tokens).

Case 5: sops creation rules share the same people

Save as .sops.yaml (desk, not their file):

# .sops.yaml
keys:
  - &alice age1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq2w4k7f
  - &bob   age1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqp9z7n0h
creation_rules:
  - path_regex: secrets/core/.*
    key_groups:
      - age: [*alice, *bob]
  - path_regex: secrets/apps/.*
    key_groups:
      - age: [*alice, *bob]

Need-to-have: secrets/apps/ does not need a laptop that only ships CI. NixOS/infra’s non-critical README says the same: add your key on a PR, someone with access runs sops updatekeys. Desk: PR to keys.nix and .sops.yaml, then sops updatekeys secrets/core/*.yaml.

The trap

The trap is pasting NixOS/infra keys.nix into the desk repo. Those keys open their machines. Generate yours. The other trap is mutableUsers = true plus this file: a human ssh-copy-ids a third key and Nix never removes it.

A third: listing alice in desk-core but forgetting .sops.yaml — she can SSH and cannot decrypt. Or the reverse: she decrypts on the laptop and cannot log in.

The boring rule

  • One keys.nix. Public keys only. Groups are concatenations.
  • mutableUsers = false. Authorized keys come from the group.
  • knownHosts from ssh.machines. Persist host keys on tmpfs roots.
  • Drop a human in keys.nix + sops recipients + apply. Then rotate.
  • Never commit private keys. Never copy another org’s pubkeys as if they were yours.

Try this

  1. Put two of your pubkeys in keys.nix. nix eval the group length.
  2. Import users-from-keys.nix on a lab VM; ssh -i as each key; remove one; switch; confirm the removed key is refused.
  3. ssh-keyscan a lab host; put it in ssh.machines; rebuild ops-01; ssh without a prompt.
  4. git grep -n 'ssh-ed25519' keys.nix — every line is a key you can name.