Operator Keys and Groups
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.length2
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).
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.knownHostsfromssh.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
- Put two of your pubkeys in
keys.nix.nix evalthe group length. - Import
users-from-keys.nixon a lab VM;ssh -ias each key; remove one; switch; confirm the removed key is refused. ssh-keyscana lab host; put it inssh.machines; rebuildops-01;sshwithout a prompt.git grep -n 'ssh-ed25519' keys.nix— every line is a key you can name.