Compare commits
1 Commits
ce103a7353
...
df5bfbbd3d
| Author | SHA1 | Date | |
|---|---|---|---|
| df5bfbbd3d |
@@ -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
17
.sops.yaml
Normal 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
|
||||||
@@ -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
23
flake.lock
generated
@@ -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": {
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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
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;
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user