Wire sops-nix into the shared base config as unconditional plumbing, with a two-tier age identity model: an admin identity held outside the repo, and a per-host identity generated on the machine and kept on its encrypted root. Both `sshKeyPaths` defaults are cleared so the SSH host keys stay out of the decryption path and remain free to become secrets in their own right. The primary user's password hash moves into a shared secrets file encrypted to admin plus neogaia, consumed through `hashedPasswordFile` and decrypted before accounts are created. This needs `users.mutableUsers = false`: NixOS applies a declared hash to an already-existing account only when that flag is false, so at the default the hand-set password would have been kept and the change would have been inert. Root consequently has no password and is locked; sudo from wheel is the way in, and generation rollback remains the recovery path.
5.0 KiB
spec
| spec |
|---|
| sops-secrets |
What to build
The tracer bullet for encrypted secrets: a working two-tier age identity model, with the primary user's login password arriving as a decrypted secret rather than a value typed into a running machine.
Establish the two identities the model rests on.
An admin identity is generated and stored as a secure note in the operator's password manager; only its public recipient ever appears in the repo, and no copy of the private half is committed in any form.
A host identity is generated on neogaia itself, onto its encrypted root, never transmitted, and deliberately not derived from the machine's SSH host key.
Commit a sops configuration naming both recipients and a shared secrets file encrypted to admin plus neogaia, holding the primary user's password hash.
The hash lives in the shared file rather than a per-host one because the same password is used on every machine, so per-host copies would only make rotation a multi-file edit.
Wire the tooling into the flake as unconditional plumbing in the shared base config — not behind an enable flag, on the same grounds as the overlays and the flakes settings. The base config carries only the machinery: the flake input, the identity file location, and the default secrets file. The password secret itself is declared beside the user declaration it feeds, so a reader finds the secret where they find its use.
The password secret must be marked as needed for user creation, which decrypts it in an earlier activation stage than ordinary secrets. That ordering is why the host identity has to sit on the root filesystem rather than anywhere mounted later.
The transition is safe on neogaia: if activation fails the rebuild fails and the running generation persists with its existing hand-set password intact.
Acceptance criteria
- An admin age identity exists in the operator's password manager; its private half is committed nowhere, in no form
- A host age identity exists on
neogaia's encrypted root and was generated on the machine - The sops configuration in the repo names the admin recipient and the
neogaiarecipient - A shared secrets file, encrypted to admin plus
neogaia, holds the primary user's password hash - The secrets flake input is added, following the base nixpkgs
- The shared base config carries the machinery unconditionally — identity file location and default secrets file — with no enable flag
- The password secret is declared beside the user declaration, consumed through
hashedPasswordFile, and marked as needed for user creation nix flake checkbuilds theneogaiatoplevel; a mistyped secret name or missing secrets file fails it- Manual confirmation:
neogaiaactivates, and console login succeeds against the decrypted password hash
Implementation Notes
users.mutableUsers = false was required and is not in the plan.
NixOS applies a declared password hash to an account that already exists in /etc/shadow only when mutableUsers is false — update-users-groups.pl guards both assignments on it.
At the default of true, alexion already existed, so hashedPasswordFile would have been ignored and the hand-set password kept, silently.
The final acceptance criterion would then have passed while proving nothing, because the login being tested would still have been the old one.
Two consequences follow, neither sanctioned by the spec.
passwd no longer works, so rotating the password means re-running mkpasswd, re-encrypting the shared file, and rebuilding.
Root has no declared password and is therefore locked (!), which blocks direct root login and the systemd emergency shell's sulogin prompt; sudo from the wheel group is unaffected, and generation rollback or init=/bin/sh remains available for recovery.
Leaving root locked was chosen over declaring a root password, on the grounds that the recovery paths that survive a locked root do not depend on /etc/shadow at all.
This is worth folding back into the parent spec before the servers exist, where a locked root and no SSH key would be a harder corner.
The negative half of the build criterion was exercised, not assumed.
A mistyped secret name fails with the key 'alexion-passwrd' cannot be found; a missing secrets file fails with Path 'secrets/absent.yaml' does not exist in Git repository.
Both were tested by temporary edits that were reverted.
Identity handling.
The admin identity was generated by the operator in a terminal outside this session, so no copy of its private half ever reached the agent or the repo.
The host identity was generated on neogaia into /var/lib/sops-nix/key.txt (mode 0400, root) on the @root subvolume of the LUKS-encrypted cryptroot, and never transmitted.
Follow-up worth flagging for 0011.
services.openssh.enable is true on neogaia with no declared authorizedKeys, so SSH is not a fallback route in if a future decryption failure locks the console.
The task that makes the SSH host keys secrets is the natural place to settle that.