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
4 changed files with 19 additions and 23 deletions

View File

@@ -43,14 +43,12 @@ The final acceptance criterion would then have passed while proving nothing, bec
Two consequences follow, neither sanctioned by the spec. 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. `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. 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.
`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. 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. 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.** **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 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`.
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. Both were tested by temporary edits that were reverted.
**Identity handling.** **Identity handling.**

View File

@@ -1,9 +1,9 @@
# Recipients for the encrypted files under secrets/. # Recipients for the encrypted files under secrets/.
keys: keys:
# A recipient of every file. # A recipient of every file:
# One readable only by machines becomes unrecoverable once they are wiped. # one readable only by machines is unrecoverable once they are wiped,
# Adding a recipient requires decrypting first. # and adding a recipient requires decrypting first.
# No private half here, only in the operator's password manager. # No private half here; it is held only in the operator's password manager.
- &admin age1m0pk94ysjlw3lmf6pyuv5l5pepvdjss8w0vxjv90dq6ndp02tdgsdwdvue - &admin age1m0pk94ysjlw3lmf6pyuv5l5pepvdjss8w0vxjv90dq6ndp02tdgsdwdvue
# Generated on the machine it names. # Generated on the machine it names.
- &neogaia age14a04vphzjq74epfrz9a09wjw8lzchtru84awzuq2n45d8f42ychqjs89qe - &neogaia age14a04vphzjq74epfrz9a09wjw8lzchtru84awzuq2n45d8f42ychqjs89qe

View File

@@ -15,12 +15,10 @@ 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.
- Start each sentence of a comment on its own line, as with Markdown prose. - Break comment lines early, at sentence and clause boundaries, rather than greedy-wrapping at the right margin.
A sentence needing more than one line is first a prompt to ask whether it should be two sentences. 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.
Only when it genuinely cannot be split does it wrap, and then it wraps normally at the right margin. A line that ends mid-noun-phrase ("keeps the SSH host keys out / of the decryption path") is the smell.
Never break a line early at a comma or clause boundary to make it read as a unit. This is the Markdown one-sentence-per-line rule applied to code comments; lines may end well short of the margin.
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. 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.

View File

@@ -69,23 +69,23 @@ in
console.useXkbConfig = true; console.useXkbConfig = true;
# Decryption machinery every host depends on. # Decryption machinery every host depends on.
# The identity sits on the encrypted root, which is mounted early enough to # The identity sits on the encrypted root,
# satisfy the secret below. # which is mounted early enough to satisfy the secret below.
# Clearing both `sshKeyPaths` defaults keeps the SSH host keys out of the # Clearing both `sshKeyPaths` defaults keeps the SSH host keys
# decryption path. # out of the decryption path.
sops.defaultSopsFile = ../secrets/shared.yaml; sops.defaultSopsFile = ../secrets/shared.yaml;
sops.age.keyFile = "/var/lib/sops-nix/key.txt"; sops.age.keyFile = "/var/lib/sops-nix/key.txt";
sops.age.sshKeyPaths = [ ]; sops.age.sshKeyPaths = [ ];
sops.gnupg.sshKeyPaths = [ ]; sops.gnupg.sshKeyPaths = [ ];
# A password set by hand on a running machine otherwise takes precedence. # A password set by hand on a running machine otherwise takes precedence,
# That leaves the declared `hashedPasswordFile` below silently inert. # leaving the declared `hashedPasswordFile` below silently inert.
# Root has no declared password and is therefore locked. # Root has no declared password and is therefore locked;
# `sudo` from the wheel group is the way in. # `sudo` from the wheel group is the way in.
users.mutableUsers = false; users.mutableUsers = false;
# Decrypted in an earlier activation stage than ordinary secrets. # Decrypted in an earlier activation stage,
# That is early enough to precede the account that reads it. # so it exists before the account that reads it.
sops.secrets.${passwordSecret}.neededForUsers = true; sops.secrets.${passwordSecret}.neededForUsers = true;
# Primary user, in the wheel group. # Primary user, in the wheel group.