feat(secrets): decrypt the login password from sops (task 0010)
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.
This commit is contained in:
@@ -24,12 +24,37 @@ The transition is safe on `neogaia`: if activation fails the rebuild fails and t
|
||||
|
||||
## 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 `neogaia` recipient
|
||||
- [ ] 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 check` builds the `neogaia` toplevel; a mistyped secret name or missing secrets file fails it
|
||||
- [ ] Manual confirmation: `neogaia` activates, and console login succeeds against the decrypted password hash
|
||||
- [x] An admin age identity exists in the operator's password manager; its private half is committed nowhere, in no form
|
||||
- [x] A host age identity exists on `neogaia`'s encrypted root and was generated on the machine
|
||||
- [x] The sops configuration in the repo names the admin recipient and the `neogaia` recipient
|
||||
- [x] A shared secrets file, encrypted to admin plus `neogaia`, holds the primary user's password hash
|
||||
- [x] The secrets flake input is added, following the base nixpkgs
|
||||
- [x] The shared base config carries the machinery unconditionally — identity file location and default secrets file — with no enable flag
|
||||
- [x] The password secret is declared beside the user declaration, consumed through `hashedPasswordFile`, and marked as needed for user creation
|
||||
- [x] `nix flake check` builds the `neogaia` toplevel; a mistyped secret name or missing secrets file fails it
|
||||
- [x] Manual confirmation: `neogaia` activates, 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.
|
||||
|
||||
16
.sops.yaml
Normal file
16
.sops.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
# Recipients for the encrypted files under secrets/.
|
||||
keys:
|
||||
# A recipient of every file: one readable only by machines is unrecoverable
|
||||
# once they are wiped, and adding a recipient requires decrypting first.
|
||||
# No private half here; it is held only in the operator's password manager.
|
||||
- &admin age1m0pk94ysjlw3lmf6pyuv5l5pepvdjss8w0vxjv90dq6ndp02tdgsdwdvue
|
||||
# Generated on the machine it names.
|
||||
- &neogaia age14a04vphzjq74epfrz9a09wjw8lzchtru84awzuq2n45d8f42ychqjs89qe
|
||||
|
||||
creation_rules:
|
||||
# Material common to every machine, so it is stored once rather than per host.
|
||||
- path_regex: secrets/shared\.yaml$
|
||||
key_groups:
|
||||
- age:
|
||||
- *admin
|
||||
- *neogaia
|
||||
23
flake.lock
generated
23
flake.lock
generated
@@ -232,7 +232,28 @@
|
||||
"nixpkgs": "nixpkgs_2",
|
||||
"nixpkgs-stable": "nixpkgs-stable",
|
||||
"nixpkgs-unstable": "nixpkgs-unstable",
|
||||
"nixvim": "nixvim"
|
||||
"nixvim": "nixvim",
|
||||
"sops-nix": "sops-nix"
|
||||
}
|
||||
},
|
||||
"sops-nix": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1783174389,
|
||||
"narHash": "sha256-aCWC8ngycU7OdJrU2+Je3qf+1a2ykuBvpPhZT/9tXMc=",
|
||||
"owner": "Mic92",
|
||||
"repo": "sops-nix",
|
||||
"rev": "f1406619a3884cd5c47992a70b8b35c9c0fcb4c9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "Mic92",
|
||||
"repo": "sops-nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
|
||||
@@ -34,6 +34,12 @@
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
# Decrypts committed secrets at activation, from an age identity on the host.
|
||||
sops-nix = {
|
||||
url = "github:Mic92/sops-nix";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
# CachyOS kernel and binary cache. Pins its own nixpkgs so its cache stays
|
||||
# usable and the kernel is fetched from it.
|
||||
chaotic.url = "github:chaotic-cx/nyx/nyxpkgs-unstable";
|
||||
|
||||
@@ -52,6 +52,7 @@ let
|
||||
inputs.home-manager.nixosModules.home-manager
|
||||
inputs.chaotic.nixosModules.default
|
||||
inputs.disko.nixosModules.disko
|
||||
inputs.sops-nix.nixosModules.sops
|
||||
(self + "/system")
|
||||
(self + "/hosts/${hostName}")
|
||||
{ networking.hostName = hostName; }
|
||||
|
||||
25
secrets/shared.yaml
Normal file
25
secrets/shared.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
alexion-password: ENC[AES256_GCM,data:EGTsJvW4/zA+FfhUtZD4E6HIsI0JQ3LLL511XjI9hDl3NaHNe4g+wWy7P5DQnW29G5W4TaPZIetUcV7OO+1btuRxTuwbWVOulA==,iv:Ihf1kz5DFtvy1bUnfQGAU0/BQrwUvqKwqnK1JbKnWHU=,tag:K0N/6H/IWesNMxzXhQQO/w==,type:str]
|
||||
sops:
|
||||
age:
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBVYzZuaERsRjMyaTAwL3Ri
|
||||
b2RhaGZ1aHNOSzVMamVxWkdKb3VKTk9QMmk4CmV0R3hYN3hkMU1tVDJLNkFlT08y
|
||||
SUdUeUZ4d2JwNmdyOWVJcmZNcEtCb1EKLS0tIHhDV1NZWWdDZUNMYjVqYUVlc0ty
|
||||
Y1owUFZPMXBHbDhjVWxTUjZGRk1IUzQK7VENq6TjuOFlon+CJqUxbIJZ9qka78C/
|
||||
LDsgaTD+7zCBPgASwPbF88pH6tdK7bvNLJnznlZdZBL12eOy25BmOQ==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1m0pk94ysjlw3lmf6pyuv5l5pepvdjss8w0vxjv90dq6ndp02tdgsdwdvue
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBHY0ZXT3lRWS9DMFA1MHhl
|
||||
MDZiWHhEMy9INGtpd1ZOdzh0OFRoUlZDa0hZCkJwTUV4c01YWlE1QjNDd3pRN3F0
|
||||
SGJWWmFTT1NMQktNejVHY1RrRlZJNFEKLS0tIHlRZG9ZV3FrQktSN2tURVV1NmlW
|
||||
UTBZbFlqMmFGZ0VPSlA1dmNMU2Q3TFUKtL2V8t9+Qw5vjXursvCVRatflX8JKXJr
|
||||
VuA8oe0nKpk7wh4fCzcT7RoRKpJY0gPFjIzeTZGVfoAmZIUWMhzRuw==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age14a04vphzjq74epfrz9a09wjw8lzchtru84awzuq2n45d8f42ychqjs89qe
|
||||
lastmodified: "2026-07-20T03:22:00Z"
|
||||
mac: ENC[AES256_GCM,data:ei7PKVAIjJ6fGkxqJFc5wdYapq1gElel3fTJ+yKhvWHU+39aKcllG66T3d9FitRztgyt69phykHdKvxDHRUwYeyl1YBzyf1ZpPU5mXJb+hkLtVB1Am7StcP+m7jFqKSmqtYhIT9OxUrH0MJ8qeoU9216otwkhhpPz2hr1s7KYFk=,iv:Pp03KmlinjJiiTZezr0LzzkcHb1a5XWgDpu38jhl9Rk=,tag:HAqFitge+KTCtDE24t8/ig==,type:str]
|
||||
unencrypted_suffix: _unencrypted
|
||||
version: 3.13.2
|
||||
@@ -10,6 +10,8 @@ let
|
||||
inherit (lib) mkOption types;
|
||||
user = config.user;
|
||||
|
||||
passwordSecret = "${user.name}-password";
|
||||
|
||||
# Args to instantiate an extra nixpkgs source on the base platform.
|
||||
pinArgs = prev: {
|
||||
inherit (prev.stdenv.hostPlatform) system;
|
||||
@@ -66,11 +68,31 @@ in
|
||||
# bare TTY and not only under a graphical session.
|
||||
console.useXkbConfig = true;
|
||||
|
||||
# Primary user, in the wheel group. No password set here.
|
||||
# Decryption machinery every host depends on. The identity sits on the
|
||||
# encrypted root, which is mounted early enough to satisfy the secret
|
||||
# below; clearing both `sshKeyPaths` defaults keeps the SSH host keys out
|
||||
# of the decryption path.
|
||||
sops.defaultSopsFile = ../secrets/shared.yaml;
|
||||
sops.age.keyFile = "/var/lib/sops-nix/key.txt";
|
||||
sops.age.sshKeyPaths = [ ];
|
||||
sops.gnupg.sshKeyPaths = [ ];
|
||||
|
||||
# A password set by hand on a running machine otherwise takes precedence
|
||||
# over the declared one, leaving `hashedPasswordFile` below silently inert.
|
||||
# Root has no declared password and is therefore locked; `sudo` from the
|
||||
# wheel group is the way in.
|
||||
users.mutableUsers = false;
|
||||
|
||||
# Decrypted in an earlier activation stage, so it exists before the account
|
||||
# that reads it.
|
||||
sops.secrets.${passwordSecret}.neededForUsers = true;
|
||||
|
||||
# Primary user, in the wheel group.
|
||||
users.users.${user.name} = {
|
||||
isNormalUser = true;
|
||||
description = user.description;
|
||||
extraGroups = [ "wheel" ];
|
||||
hashedPasswordFile = config.sops.secrets.${passwordSecret}.path;
|
||||
};
|
||||
|
||||
# home-manager as a NixOS module: one `nixos-rebuild switch` builds the
|
||||
|
||||
Reference in New Issue
Block a user