docs(install): revise for sops-provisioned identities (task 0012)

The procedure was built around a login password set by hand through
nixos-enter, a step that no longer exists now that the password arrives
as a decrypted secret. Rather than patch around it, revise the document:
the LUKS passphrase is named as the only hand-entered secret, and identity
provisioning moves ahead of first boot, since there is no fallback
credential to get in with if decryption fails.

Cover the three procedures the install alone did not: provisioning a
running host live, the editing workflow and what it needs the admin
identity for, and recovery from the live ISO.
This commit was merged in pull request #6.
This commit is contained in:
2026-07-20 10:04:12 -04:00
parent 11d7cb053c
commit dcc03155a2
2 changed files with 305 additions and 51 deletions

View File

@@ -26,12 +26,43 @@ Everything goes in the existing install document; no new document is introduced.
## Acceptance criteria
- [ ] No step remains that sets a login password by hand, and nothing instructs the operator to log in with one
- [ ] The document's framing names the LUKS passphrase as the only hand-entered secret
- [ ] The install ordering places identity provisioning before first boot, and states why there is no fallback credential
- [ ] The closing section no longer describes deriving identities from SSH host keys or defers sops wiring to a post-boot follow-up
- [ ] Live provisioning for an already-running host is documented
- [ ] Pre-install provisioning on the live ISO for a not-yet-existing host is documented, including that the recipient change is committed after the install
- [ ] The editing workflow is documented, distinguishing what the workstation can re-key alone from what needs the admin identity
- [ ] The live-ISO recovery path for a wrongly-provisioned machine is documented
- [ ] The document reads end to end as one coherent procedure for a reader who has never seen the previous version
- [x] No step remains that sets a login password by hand, and nothing instructs the operator to log in with one
- [x] The document's framing names the LUKS passphrase as the only hand-entered secret
- [x] The install ordering places identity provisioning before first boot, and states why there is no fallback credential
- [x] The closing section no longer describes deriving identities from SSH host keys or defers sops wiring to a post-boot follow-up
- [x] Live provisioning for an already-running host is documented
- [x] Pre-install provisioning on the live ISO for a not-yet-existing host is documented, including that the recipient change is committed after the install
- [x] The editing workflow is documented, distinguishing what the workstation can re-key alone from what needs the admin identity
- [x] The live-ISO recovery path for a wrongly-provisioned machine is documented
- [x] The document reads end to end as one coherent procedure for a reader who has never seen the previous version
## Implementation Notes
**The document was retitled and given a table of contents.**
Three of the four procedures are not installs, so "Installing `neogaia`" no longer described the contents.
It is now "Installing and provisioning a host", and the install procedure is one section among four rather than the whole document.
The install steps stay concrete about `neogaia` and its NVMe device, since that is the only machine the flake installs today and a generic example would be less useful than a real one.
**Commands were verified against the running system rather than written from memory.**
Neither `sops` nor `age` is packaged by this flake, so every invocation goes through `nix run nixpkgs#sops` or `nix shell nixpkgs#age -c age-keygen`, and the document says so up front.
`sops updatekeys`, `age-keygen -y`, and `nixos-install --root/--flake/--no-root-password` were each confirmed to exist.
The identity file's `0400 root:root` and `/var/lib/sops-nix/key.txt` were read off the live machine, and `/var` was confirmed to sit on the `@root` subvolume, which is what makes the path valid before user creation.
**Review caught four factual errors, all corrected.**
The most consequential: the post-provisioning check said `ls -l /run/secrets/`, but `neededForUsers` puts the password hash in `/run/secrets-for-users/` — confirmed by `nix eval`, which returns `/run/secrets-for-users/alexion-password`.
The one secret whose failure causes the lockout the document exists to prevent was the one the reader was told not to look at.
Also fixed: the `.sops.yaml` example added a new host to the shared rule but not a rule for its own file, which makes `sops` refuse it with `no matching creation rules found`; step 8's `updatekeys` omitted `SOPS_AGE_KEY_FILE`, so it would have looked in `~/.config/sops/age/keys.txt` rather than the root-owned identity; and "create its file now" did not say that a host enabling the SSH daemon needs `ssh-host-<type>-key` entries or the build fails at evaluation.
**Deliberate redundancy in the recovery path.**
Review flagged the disko remount block and the re-key sequence as duplicated between the install and recovery sections.
They are left duplicated on purpose: an operator running the recovery procedure is locked out of the machine, and sending them to page back into the install steps mid-recovery is worse than the maintenance cost of two copies.
The already-running-host procedure does cross-reference step 4, because that reader has a working machine and can follow a link.
**The recovery procedure is documented but unexercised.**
Both branches follow from verified facts — `nixos-install` is idempotent and reuses the formatted disk, and the secrets file is baked into the closure at build time, which is why one branch needs a rebuild and the other does not.
Neither has been run, because doing so requires deliberately locking out the only machine.
The claim that no fallback credential exists was checked rather than assumed: there is no `authorizedKeys`, no root password, and `mutableUsers = false`.
**Follow-up.**
Task 0019 adds user SSH keys, which will make "no authorized SSH key" in the no-fallback paragraph stale.
That paragraph is the place to revisit when it lands, since an authorized key would become a genuine second way in.

View File

@@ -1,24 +1,52 @@
# Installing `neogaia`
# Installing and provisioning a host
This is the one-shot procedure that turns the `neogaia` `Host` in this flake into a running, encrypted Dell XPS 13 laptop, installed from the NixOS live ISO.
This document covers the procedures that put a machine into the fleet and keep its secrets readable.
It is destructive: it formats `/dev/nvme0n1` in full.
Read it end to end before starting, because the laptop is the only machine and the reimage is irreversible.
- [Installing a host from the live ISO](#installing-a-host-from-the-live-iso), the destructive one-shot that turns a host in this flake into a running, encrypted machine.
- [Provisioning an already-running host](#provisioning-an-already-running-host), done live on the machine with no reimage.
- [Editing secrets](#editing-secrets), the day-to-day workflow.
- [Recovering a wrongly-provisioned machine](#recovering-a-wrongly-provisioned-machine) from the live ISO.
The whole install is a single `disko-install` against the `neogaia` `Host`, followed by setting a bootstrap login password by hand.
Everything the installed system needs — the LUKS layout, the CachyOS kernel, the wifi firmware, the user, and the terminal tooling — is already declared in the flake, so this document is only the mechanics of getting that flake onto the disk.
The install is destructive: it formats the target disk in full.
Read it end to end before starting, because on a single-machine fleet the reimage is irreversible.
## Bootstrap ordering
## What arrives by hand
The install builds from a clone of this repo, so **the flake must already be on Gitea before you start** — the live ISO cannot clone a config that has not been pushed.
The bootstrap login password is likewise set by hand at the end and is **never committed**, which is what keeps the public repo free of any secret while still yielding a working login on first boot.
Exactly one secret is entered by hand: the **LUKS passphrase** that encrypts the disk, typed when the disk is formatted and again at every boot.
Two secrets are set by hand during this install, both entered interactively and neither stored in the repo:
Everything else arrives declared.
The login password is a `sops`-encrypted secret consumed through `hashedPasswordFile`, and the SSH host keys are restored from secrets rather than generated.
No password is set interactively at any point, and `users.mutableUsers = false` means one set by hand would be ignored anyway.
1. The **LUKS passphrase** that encrypts the disk, entered when `disko-install` formats it and again at every boot.
2. The **bootstrap login password** for the `alexion` user, set through `nixos-enter` just before the reboot.
## Identity before first boot
## 0. Push the repo to Gitea
A machine reads its secrets with an **age identity** at `/var/lib/sops-nix/key.txt` on its encrypted root.
Its public half must be registered as a recipient of every secrets file the machine needs, and the re-keyed files must be in the flake's git tree when the system is built, because the ciphertext is baked into the store.
**A host's identity is therefore generated and registered before its first boot, not after it.**
The login password arrives only from a decrypted secret and there is no fallback credential — no interactive password, no unlocked root account, no authorized SSH key.
A first boot without a registered identity cannot decrypt the password hash, so the account it would log in as has no usable password and the machine has no way in short of the [recovery procedure](#recovering-a-wrongly-provisioned-machine).
Identities come in two tiers.
The **admin identity** lives in Proton Pass, is a recipient of every file, and is the credential that authorizes registering a new host.
Each **host identity** is generated on its own machine, never transmitted, and reads only that machine's file plus the shared one.
A host identity is deliberately not derived from the machine's SSH host key, which is what frees those host keys to be secrets in their own right.
## Tooling
Neither `sops` nor `age` is installed by this flake.
Run them from nixpkgs as needed:
```console
$ nix run nixpkgs#sops -- <args>
$ nix shell nixpkgs#age -c age-keygen <args>
```
On the live ISO these need `--extra-experimental-features 'nix-command flakes'`, since the ISO's daemon has neither enabled.
## Installing a host from the live ISO
### 0. Push the repo to Gitea
From your working checkout, make sure `main` is committed and pushed to the Gitea remote:
@@ -26,11 +54,12 @@ From your working checkout, make sure `main` is committed and pushed to the Gite
$ git push origin main
```
The install reads only committed, git-tracked content, so anything uncommitted will not make it onto the laptop.
The clone in step 2 sees only what has been pushed, so anything left behind in your working checkout will not reach the machine.
Changes made inside that clone afterwards are a separate matter — step 4 makes one there deliberately.
## 1. Boot the live ISO and join wifi
### 1. Boot the live ISO and join wifi
Boot the machine from a NixOS live ISO (the minimal installer is enough).
Boot from a NixOS live ISO (the minimal installer is enough).
The installer logs in as the `nixos` user, who has passwordless `sudo`.
On the minimal ISO, bring up wifi with `wpa_supplicant`:
@@ -54,7 +83,7 @@ $ nmcli device wifi connect "YOUR_SSID" password "YOUR_WIFI_PASSWORD"
Confirm you have connectivity (`ping -c1 github.com`) before continuing.
## 2. Clone the repo locally
### 2. Clone the repo locally
Clone this repo onto the live ISO and work from that local checkout:
@@ -76,7 +105,86 @@ Gitea serves HTTPS with a self-signed certificate and expects authentication, an
A plain `git clone` sidesteps that entirely — over SSH there is no TLS, and over HTTPS git takes the `sslVerify=false` above that the flake fetcher won't — and then `disko-install` consumes the flake from a local path, where no fetch of our repo happens during the build.
(Every other flake input is public and still fetched from GitHub over ordinary, valid TLS; only our own repo is the problem the local clone solves.)
## 3. Run `disko-install`
### 3. Generate the host identity
Generate the identity in the live session and keep it there until step 6 writes it onto the installed root:
```console
$ nix shell nixpkgs#age -c age-keygen -o /tmp/key.txt
Public key: age1...
```
`age-keygen` prints the public recipient on generation.
Recover it later from the identity itself if the line scrolls away:
```console
$ nix shell nixpkgs#age -c age-keygen -y /tmp/key.txt
```
The private half never leaves this session except onto the target disk.
Do not copy it into the repo, and do not carry it to another machine.
### 4. Register the recipient and re-key
Add the public recipient to `.sops.yaml` as a named anchor, then list it under every file the host must read:
```yaml
keys:
- &admin age1m0pk94ysjlw3lmf6pyuv5l5pepvdjss8w0vxjv90dq6ndp02tdgsdwdvue
- &neogaia age14a04vphzjq74epfrz9a09wjw8lzchtru84awzuq2n45d8f42ychqjs89qe
- &newhost age1...
creation_rules:
- path_regex: secrets/shared\.yaml$
key_groups:
- age:
- *admin
- *neogaia
- *newhost
```
If the host gets a secrets file of its own, give it a rule too.
`sops` matches a file against these rules to decide who to encrypt it to, and refuses a file no rule matches with `no matching creation rules found`:
```yaml
- path_regex: secrets/newhost\.yaml$
key_groups:
- age:
- *admin
- *newhost
```
Then re-key each file you changed, which rewrites its data key for the new recipient list without touching any value:
```console
$ SOPS_AGE_KEY_FILE=/path/to/admin-identity \
nix run nixpkgs#sops -- updatekeys secrets/shared.yaml
```
Re-keying requires an identity that can already decrypt the file.
The live ISO holds no host identity of its own, so paste the admin identity out of Proton Pass into a file in the live session for this step.
Then populate that file, which needs no existing identity because encrypting only reads recipients:
```console
$ nix run nixpkgs#sops -- secrets/newhost.yaml
```
A host with `modules.ssh.enable` expects one entry per key type, named `ssh-host-<type>-key`, each holding a private key generated with `ssh-keygen -t <type> -N "" -f /tmp/<type>`.
The build fails at evaluation if a declared secret is absent from the file, so a host that enables the daemon without these will not install.
Commit the matching public halves beside the host's configuration in plaintext, since publishing them is their purpose.
Stage everything you changed.
A flake sees only git-tracked files, so an unstaged `secrets/newhost.yaml` is invisible to evaluation even though it exists on disk:
```console
$ git add .sops.yaml secrets/
```
Staging is enough for the build.
The commit comes in step 8, and no push is needed here because the install builds from this local clone.
### 5. Run `disko-install`
Run the install as root from inside the clone:
@@ -94,7 +202,7 @@ What each part does:
- `--flake .#neogaia` installs the `neogaia` `Host` from the local clone.
- `--disk main /dev/nvme0n1` maps disko's `main` disk to the NVMe device; it matches the device declared in `hosts/neogaia/disk.nix` and is stated explicitly so there is no doubt about the target.
- `--write-efi-boot-entries` writes the systemd-boot entry into this machine's NVRAM, because the disk stays in the laptop it was installed from.
- `--write-efi-boot-entries` writes the systemd-boot entry into this machine's NVRAM, because the disk stays in the machine it was installed from.
- The two `--option` lines are the important part: they hand the **chaotic binary cache** to the install-time Nix daemon on the live ISO.
The chaotic substituter must be passed here explicitly.
@@ -106,14 +214,11 @@ Partway through, disko formats the LUKS container and **prompts for a disk-encry
This is the passphrase you will type at every boot to unlock the disk; choose it deliberately.
When it finishes it prints `disko-install succeeded`.
`disko-install` unmounts the target filesystem on exit, so nothing is mounted at this point — step 4 remounts it.
`disko-install` unmounts the target filesystem on exit, so nothing is mounted at this point — step 6 remounts it.
## 4. Set the bootstrap login password
### 6. Write the identity onto the installed root
The installed system was written with no login password (`nixos-install --no-root-password`, and the flake sets none for `alexion`), so it cannot yet be logged into.
Set a bootstrap password by hand before rebooting.
First remount the just-installed system with disko, which reopens the LUKS container (prompting for the passphrase from step 3) and mounts the subvolumes under `/mnt`:
Remount the just-installed system with disko, which reopens the LUKS container (prompting for the passphrase from step 5) and mounts the subvolumes under `/mnt`:
```console
$ sudo nix --extra-experimental-features 'nix-command flakes' run \
@@ -121,17 +226,22 @@ $ sudo nix --extra-experimental-features 'nix-command flakes' run \
--mode mount --flake .#neogaia
```
Then enter the installed system and set the password for your user:
Then place the identity generated in step 3, owned by root and readable by nobody else:
```console
$ sudo nixos-enter --root /mnt
[nixos-enter]# passwd alexion
[nixos-enter]# exit
$ sudo install -d -m 0755 /mnt/var/lib/sops-nix
$ sudo install -m 0400 -o root -g root /tmp/key.txt /mnt/var/lib/sops-nix/key.txt
```
This password lives only on the laptop's disk; it is **never committed** anywhere.
It goes on the root subvolume rather than anywhere mounted later because the password secret is decrypted before user accounts are created, which is earlier than any other mount.
## 5. Reboot
Confirm the identity matches the recipient you registered before rebooting, since this is the last cheap moment to catch a mismatch:
```console
$ sudo nix shell nixpkgs#age -c age-keygen -y /mnt/var/lib/sops-nix/key.txt
```
### 7. Reboot
Unmount and reboot into the installed system:
@@ -141,19 +251,132 @@ $ sudo reboot
```
Remove the USB stick.
At boot you are prompted for the LUKS passphrase from step 3; after unlocking, log in at the console as `alexion` with the bootstrap password from step 4 and you have a working system with fish, tmux, nvim, and Claude Code.
At boot you are prompted for the LUKS passphrase from step 5.
After unlocking, log in at the console as `alexion` with the password from the shared secrets file, and you have a working system with fish, tmux, nvim, and Claude Code.
## First post-boot task
If the login is rejected, the identity and the registered recipient disagree — see [recovery](#recovering-a-wrongly-provisioned-machine).
Setting the login password by hand is a bootstrap shortcut, not the end state.
The first thing to do on the running laptop is to move that password to a `hashedPasswordFile` backed by a `sops-nix` secret, so it is declared and reproducible like everything else.
### 8. Commit the recipient change
Per ADR 0002, secrets are decrypted by two tiers of age identity: an admin identity held in a password manager, which is a recipient of every secrets file, and a per-`Host` identity generated on that machine's encrypted root.
A `Host` identity is deliberately *not* derived from its SSH host key, which is what allows the SSH host keys to become secrets in their own right and survive a reimage.
The re-key from step 4 exists only in the live session's clone, which is gone.
From a machine that is already a recipient of the affected files, repeat the `.sops.yaml` edit and re-key, then commit and push:
That wiring is out of scope here because this procedure is what produces the machine the identity is generated on.
```console
$ sudo SOPS_AGE_KEY_FILE=/var/lib/sops-nix/key.txt \
nix run nixpkgs#sops -- updatekeys secrets/shared.yaml
$ git add .sops.yaml secrets/
$ git commit -m "feat(secrets): register newhost as a recipient"
$ git push origin main
```
**Once the password is a secret, step 4 of this procedure stops working.**
`hashedPasswordFile` takes precedence over every other password option, so `passwd` under `nixos-enter` no longer yields a login, and no fallback setting can override it.
From that point on, a `Host` must have its identity provisioned and registered as a recipient *before* its first boot, or it boots with no usable password.
Anyone reinstalling after the secrets work lands should follow the secrets provisioning procedure rather than step 4 as written.
Until this lands, the repo's copy of each file has one recipient fewer than the copy the new machine was built from, and the next rebuild from the repo would lock it out.
## Provisioning an already-running host
A machine that is up and running gets its identity live.
There is no reimage and no live ISO, because the running generation is the fallback: if activation fails, the rebuild fails and the machine keeps working as it is.
Generate the identity on the machine itself, straight into place:
```console
$ sudo install -d -m 0755 /var/lib/sops-nix
$ sudo nix shell nixpkgs#age -c age-keygen -o /var/lib/sops-nix/key.txt
$ sudo chmod 0400 /var/lib/sops-nix/key.txt
```
Register the printed public recipient in `.sops.yaml` and re-key each file the host must read, exactly as in [step 4](#4-register-the-recipient-and-re-key), using the admin identity.
Then rebuild:
```console
$ sudo nixos-rebuild switch --flake .#neogaia
```
Activation decrypts the secrets with the new identity.
Confirm they materialized before trusting the change:
```console
$ sudo ls -l /run/secrets/ /run/secrets-for-users/
```
Both directories matter.
Ordinary secrets land in `/run/secrets/`, but a secret marked as needed for user creation is decrypted in an earlier stage and lands in `/run/secrets-for-users/` — which is where the login password hash goes, so it is the one to check before rebooting.
Commit and push the recipient change once the rebuild succeeds.
## Editing secrets
Opening a file decrypts it into an editor and re-encrypts on save:
```console
$ sudo SOPS_AGE_KEY_FILE=/var/lib/sops-nix/key.txt \
nix run nixpkgs#sops -- secrets/shared.yaml
```
`sudo` is needed because the identity is mode `0400` and owned by root.
**What the workstation can do alone** is anything to a file it is already a recipient of.
For `neogaia` that is `secrets/shared.yaml` and `secrets/neogaia.yaml`: changing a value, adding a key, and even adding another recipient all work from the host identity, because each only requires decrypting a file the machine can already decrypt.
**What needs the admin identity** is any file the workstation is not a recipient of — another machine's `secrets/<host>.yaml`.
Unlock the admin identity out of Proton Pass for that session and point `SOPS_AGE_KEY_FILE` at it:
```console
$ SOPS_AGE_KEY_FILE=/path/to/admin-identity \
nix run nixpkgs#sops -- secrets/zeus.yaml
```
That friction is the point.
A workstation that could decrypt every machine's material would make the admin identity ceremonial, and a compromised laptop would carry the whole fleet with it.
The admin identity stays a break-glass credential rather than something sitting unlocked on a machine.
Two changes need more than a save.
Rotating the login password means generating a fresh hash with `mkpasswd`, since `users.mutableUsers = false` makes `passwd` inert, and rebuilding.
Re-keying the SSH host keys restarts `sshd`, which is declared and automatic.
## Recovering a wrongly-provisioned machine
A machine whose identity and registered recipient disagree boots but cannot be logged into: the password hash never decrypts, and there is no fallback credential.
Recovery is from the live ISO.
Boot the ISO, join wifi, and clone the repo as in steps 1 and 2.
Then reopen and mount the encrypted root:
```console
$ sudo nix --extra-experimental-features 'nix-command flakes' run \
github:nix-community/disko/latest#disko -- \
--mode mount --flake .#neogaia
```
Read the identity actually on the disk, and compare it against the recipient the repo registered:
```console
$ sudo nix shell nixpkgs#age -c age-keygen -y /mnt/var/lib/sops-nix/key.txt
```
**If the repo's recipient is right and the disk's identity is wrong**, replace the identity with the one that matches and reboot.
Nothing was built against the wrong key, so no rebuild is needed:
```console
$ sudo install -m 0400 -o root -g root /path/to/correct-key.txt /mnt/var/lib/sops-nix/key.txt
$ sudo umount -R /mnt && sudo reboot
```
**If the disk's identity is right and the repo's recipient is wrong**, re-key against the identity on the disk, using the admin identity to decrypt, then rebuild the target from the ISO:
```console
$ SOPS_AGE_KEY_FILE=/path/to/admin-identity \
nix run nixpkgs#sops -- updatekeys secrets/shared.yaml
$ git add .sops.yaml secrets/
$ sudo NIX_CONFIG="experimental-features = nix-command flakes" \
nixos-install --root /mnt --flake .#neogaia --no-root-password \
--option extra-substituters https://nyx-cache.chaotic.cx/ \
--option extra-trusted-public-keys nyx-cache.chaotic.cx:dJxTrgMC3V3cFfyIiBQDQorG6k1LsqurH/srpMSq7qk=
$ sudo umount -R /mnt && sudo reboot
```
The rebuild is required here and not in the first case, because the secrets file is baked into the system closure at build time.
`nixos-install` reuses the already-formatted disk rather than touching the partition table, so the LUKS container and its passphrase are untouched, and it is idempotent if it fails partway.
The substituter flags matter for the same reason they do during the install: without them the CachyOS kernel is compiled from source on the USB stick.
If neither identity is recoverable, generate a new one as in [step 3](#3-generate-the-host-identity), register it, re-key, and rebuild — the machine's own secrets are lost, but everything encrypted to the admin identity survives.