Compare commits

...

1 Commits

Author SHA1 Message Date
df5bfbbd3d 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.
2026-07-19 23:38:28 -04:00
8 changed files with 134 additions and 11 deletions

View File

@@ -24,12 +24,37 @@ The transition is safe on `neogaia`: if activation fails the rebuild fails and t
## Acceptance criteria ## Acceptance criteria
- [ ] An admin age identity exists in the operator's password manager; its private half is committed nowhere, in no form - [x] 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 - [x] 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 - [x] 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 - [x] 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 - [x] 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 - [x] 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 - [x] 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 - [x] `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] 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.

17
.sops.yaml Normal file
View File

@@ -0,0 +1,17 @@
# 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

View File

@@ -15,6 +15,11 @@ 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. 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 <missing feature>". 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 <missing feature>".
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. 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.
- Break comment lines early, at sentence and clause boundaries, rather than greedy-wrapping at the right margin.
Start each sentence on its own line, and when one sentence needs several lines, break it at a comma or semicolon rather than mid-phrase.
A line that ends mid-noun-phrase ("keeps the SSH host keys out / of the decryption path") is the smell.
This is the Markdown one-sentence-per-line rule applied to code comments; lines may end well short of the margin.
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. - 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. 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.) (A dedicated bot account may replace this later; until then, the sign-off is the only marker.)

23
flake.lock generated
View File

@@ -232,7 +232,28 @@
"nixpkgs": "nixpkgs_2", "nixpkgs": "nixpkgs_2",
"nixpkgs-stable": "nixpkgs-stable", "nixpkgs-stable": "nixpkgs-stable",
"nixpkgs-unstable": "nixpkgs-unstable", "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": { "systems": {

View File

@@ -34,6 +34,12 @@
inputs.nixpkgs.follows = "nixpkgs"; 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 # CachyOS kernel and binary cache. Pins its own nixpkgs so its cache stays
# usable and the kernel is fetched from it. # usable and the kernel is fetched from it.
chaotic.url = "github:chaotic-cx/nyx/nyxpkgs-unstable"; chaotic.url = "github:chaotic-cx/nyx/nyxpkgs-unstable";

View File

@@ -52,6 +52,7 @@ let
inputs.home-manager.nixosModules.home-manager inputs.home-manager.nixosModules.home-manager
inputs.chaotic.nixosModules.default inputs.chaotic.nixosModules.default
inputs.disko.nixosModules.disko inputs.disko.nixosModules.disko
inputs.sops-nix.nixosModules.sops
(self + "/system") (self + "/system")
(self + "/hosts/${hostName}") (self + "/hosts/${hostName}")
{ networking.hostName = hostName; } { networking.hostName = hostName; }

25
secrets/shared.yaml Normal file
View 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

View File

@@ -10,6 +10,8 @@ let
inherit (lib) mkOption types; inherit (lib) mkOption types;
user = config.user; user = config.user;
passwordSecret = "${user.name}-password";
# Args to instantiate an extra nixpkgs source on the base platform. # Args to instantiate an extra nixpkgs source on the base platform.
pinArgs = prev: { pinArgs = prev: {
inherit (prev.stdenv.hostPlatform) system; inherit (prev.stdenv.hostPlatform) system;
@@ -66,11 +68,32 @@ in
# bare TTY and not only under a graphical session. # bare TTY and not only under a graphical session.
console.useXkbConfig = true; 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,
# leaving 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,
# so it exists before the account that reads it.
sops.secrets.${passwordSecret}.neededForUsers = true;
# Primary user, in the wheel group.
users.users.${user.name} = { users.users.${user.name} = {
isNormalUser = true; isNormalUser = true;
description = user.description; description = user.description;
extraGroups = [ "wheel" ]; extraGroups = [ "wheel" ];
hashedPasswordFile = config.sops.secrets.${passwordSecret}.path;
}; };
# home-manager as a NixOS module: one `nixos-rebuild switch` builds the # home-manager as a NixOS module: one `nixos-rebuild switch` builds the