diff --git a/.claude/tasks/0010-sops-skeleton-and-password.md b/.claude/tasks/0010-sops-skeleton-and-password.md index 5d0afeb..45e81d7 100644 --- a/.claude/tasks/0010-sops-skeleton-and-password.md +++ b/.claude/tasks/0010-sops-skeleton-and-password.md @@ -24,12 +24,39 @@ 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..d91b7b5 --- /dev/null +++ b/.sops.yaml @@ -0,0 +1,17 @@ +# Recipients for the encrypted files under secrets/. +keys: + # A recipient of every file. + # One readable only by machines becomes unrecoverable once they are wiped. + # Adding a recipient requires decrypting first. + # No private half here, 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/CLAUDE.md b/CLAUDE.md index d79d698..e991708 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,6 +15,13 @@ The domain model (Host, Module, Skeleton, Auto-loader, Enable convention, overla A file-top header is one concise purpose line, added only where the filename or path does not already say it — never a feature inventory of the code below. For a placeholder, say so plainly plus any actionable present-tense directive ("Placeholder: regenerate with nixos-generate-config on the target machine"), never "placeholder for ". Option `description`/`mkEnableOption` strings are user-facing documentation rather than comments, so they may describe behaviour more fully — but the self-contained rule and the bans on glossary terms and agent-state references still apply. +- Start each sentence of a comment on its own line, as with Markdown prose. + A sentence needing more than one line is first a prompt to ask whether it should be two sentences. + Only when it genuinely cannot be split does it wrap, and then it wraps normally at the right margin. + Never break a line early at a comma or clause boundary to make it read as a unit. + Never use a semicolon, in a comment or in authored prose. + Recast as two sentences instead. + Only reformat comments you are actually writing or changing. - Comments posted to Gitea (pull requests, issues, reviews) go out under the operator's account, so sign every one to make clear the author is the agent, not the operator. End the comment with a `— Claude` sign-off. (A dedicated bot account may replace this later; until then, the sign-off is the only marker.) 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..f056835 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,32 @@ 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. + # That leaves the declared `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 than ordinary secrets. + # That is early enough to precede 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