diff --git a/.claude/tasks/0010-sops-skeleton-and-password.md b/.claude/tasks/0010-sops-skeleton-and-password.md index 5d0afeb..63f86ec 100644 --- a/.claude/tasks/0010-sops-skeleton-and-password.md +++ b/.claude/tasks/0010-sops-skeleton-and-password.md @@ -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. diff --git a/.sops.yaml b/.sops.yaml new file mode 100644 index 0000000..f06c051 --- /dev/null +++ b/.sops.yaml @@ -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 diff --git a/flake.lock b/flake.lock index 403f7a0..4f1fa33 100644 --- a/flake.lock +++ b/flake.lock @@ -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": { diff --git a/flake.nix b/flake.nix index 8203cad..db74320 100644 --- a/flake.nix +++ b/flake.nix @@ -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"; diff --git a/lib/default.nix b/lib/default.nix index ca62fd5..3fa5dc1 100644 --- a/lib/default.nix +++ b/lib/default.nix @@ -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; } diff --git a/secrets/shared.yaml b/secrets/shared.yaml new file mode 100644 index 0000000..1665f91 --- /dev/null +++ b/secrets/shared.yaml @@ -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 diff --git a/system/default.nix b/system/default.nix index b99cd6e..2152daa 100644 --- a/system/default.nix +++ b/system/default.nix @@ -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