Compare commits
3 Commits
7809e079e3
...
5e254857b9
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e254857b9 | |||
| 7cd9370366 | |||
| 066bf467ba |
@@ -24,6 +24,20 @@ _Avoid_: loader, importer, scanner
|
|||||||
The rule that every Module is imported unconditionally and guards its own body with `mkIf config.modules.<path>.enable`, so a Host reads as a checklist of `enable = true` flags.
|
The rule that every Module is imported unconditionally and guards its own body with `mkIf config.modules.<path>.enable`, so a Host reads as a checklist of `enable = true` flags.
|
||||||
_Avoid_: feature flag, toggle, opt-in
|
_Avoid_: feature flag, toggle, opt-in
|
||||||
|
|
||||||
|
**admin identity**:
|
||||||
|
The age identity held only in the operator's password manager, never committed, that is a recipient of every secrets file.
|
||||||
|
It is the recovery path for any wiped machine and the credential that authorizes registering a new host.
|
||||||
|
_Avoid_: master key, admin key, root key
|
||||||
|
|
||||||
|
**host identity**:
|
||||||
|
The dedicated age key on one machine's encrypted root, generated there and never transmitted, that decrypts that machine's own secrets and the shared file.
|
||||||
|
Deliberately distinct from the machine's SSH host key.
|
||||||
|
_Avoid_: machine key, node key, host key
|
||||||
|
|
||||||
|
**secrets file**:
|
||||||
|
One sops-encrypted file in the repo, encrypted to the admin identity plus whichever hosts may read it. Either shared across every host or specific to one.
|
||||||
|
_Avoid_: vault, secret store, keyring
|
||||||
|
|
||||||
**unstable overlay**:
|
**unstable overlay**:
|
||||||
The overlay exposing `nixpkgs-unstable` packages as `unstable.<name>`, used to pull an individual package fresher than the `nixos-unstable` base.
|
The overlay exposing `nixpkgs-unstable` packages as `unstable.<name>`, used to pull an individual package fresher than the `nixos-unstable` base.
|
||||||
_Avoid_: bleeding-edge, latest
|
_Avoid_: bleeding-edge, latest
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
---
|
||||||
|
status: superseded by ADR-0002
|
||||||
|
---
|
||||||
|
|
||||||
# Use sops-nix for secrets
|
# Use sops-nix for secrets
|
||||||
|
|
||||||
The repo is public, so no secret — including password hashes and the WireGuard/ProtonVPN key — may be committed in plaintext. We manage all secrets with **sops-nix**: encrypted into the repo and decrypted per-host at activation via an age key derived from each machine's SSH host key.
|
The repo is public, so no secret — including password hashes and the WireGuard/ProtonVPN key — may be committed in plaintext. We manage all secrets with **sops-nix**: encrypted into the repo and decrypted per-host at activation via an age key derived from each machine's SSH host key.
|
||||||
|
|||||||
25
.claude/adr/0002-admin-and-host-age-identities.md
Normal file
25
.claude/adr/0002-admin-and-host-age-identities.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
status: accepted
|
||||||
|
---
|
||||||
|
|
||||||
|
# Two-tier age identities, secrets in the public repo
|
||||||
|
|
||||||
|
Secrets are encrypted with sops-nix into this public repo and decrypted by a two-tier set of age identities: one **admin identity**, stored only in Proton Pass and never committed, which is a recipient of every secrets file; and one **host identity** per machine, a dedicated age key generated on that machine's encrypted root, which reads only its own secrets plus the shared file.
|
||||||
|
The admin identity makes secrets recoverable after any machine is wiped and is the credential that authorizes registering a new host; the host identities keep a compromised server from decrypting the laptop.
|
||||||
|
Deliberately, a host identity is *not* derived from its SSH host key — that decoupling is what lets the SSH host keys themselves be secrets, so they survive a reimage instead of being regenerated.
|
||||||
|
|
||||||
|
This supersedes ADR 0001, whose choice of sops-nix over agenix still holds — the shared-plus-per-host file split with overlapping recipients is exactly the multi-recipient, grouped-file model that decided against agenix — but whose key-derivation mechanism is replaced.
|
||||||
|
|
||||||
|
## Considered Options
|
||||||
|
|
||||||
|
- **A separate private repository for secrets.** Rejected: cloning it needs credentials that would themselves be bootstrap material during an install, reintroducing a hand-carried secret to protect ciphertext that is already safe to publish.
|
||||||
|
- **A passphrase-encrypted admin identity committed to the repo.** Rejected: in a public repo it is offline-brute-forceable indefinitely, whereas a password manager provides the same protection with rate limiting.
|
||||||
|
- **Deriving host identities from SSH host keys**, as ADR 0001 specified. Rejected: it forces new host keys on every reimage, which means re-keying every secret, and it makes storing the host keys as secrets circular.
|
||||||
|
- **A single admin identity for all hosts, with no per-host identities.** Rejected: with three servers planned, it gives every machine the ability to decrypt every other machine's secrets.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Every secrets file must include the admin identity as a recipient. A file readable only by its own host becomes permanently unrecoverable the moment that machine is wiped.
|
||||||
|
- The admin identity is the single point of recovery, and its durability is now a property of Proton Pass rather than of any machine or repository.
|
||||||
|
- A host must have its identity provisioned and registered *before* its first boot, because the login password now arrives only from a decrypted secret and there is no fallback credential.
|
||||||
|
- Registering a new host is a re-key of each file's data key, not a re-encryption of its values, so the cost stays constant as the fleet grows.
|
||||||
148
.claude/spec/sops-secrets.md
Normal file
148
.claude/spec/sops-secrets.md
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
`neogaia` is up and running the NixOS it builds, but its login password was set by hand through `nixos-enter` during the install and lives only on that laptop's disk.
|
||||||
|
It is the one piece of the machine that is not declared, not reproducible, and not recoverable — a reimage loses it, and no other `Host` can inherit it.
|
||||||
|
|
||||||
|
The same gap blocks everything queued behind it.
|
||||||
|
An Anthropic API key cannot be provisioned declaratively, a WireGuard key cannot be committed, and the three planned servers cannot carry service credentials.
|
||||||
|
The repo is public and mirrored to GitHub, so none of that material can be committed in plaintext.
|
||||||
|
|
||||||
|
There is a second, subtler cost.
|
||||||
|
SSH host keys are currently generated fresh by `sshd` on each install, so reimaging any machine invalidates its host identity and breaks `known_hosts` for every client that ever connected to it.
|
||||||
|
|
||||||
|
`ADR 0001` chose `sops-nix` for this, but its stated mechanism — age keys derived from each `Host`'s SSH host key — turns out to be the wrong topology, and its consequences no longer describe what should be built.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
Wire `sops-nix` into the `Skeleton` as unconditional plumbing, with a two-tier age identity model.
|
||||||
|
|
||||||
|
An **admin identity** stored outside the repo entirely, in Proton Pass, is a recipient of every secrets file.
|
||||||
|
It is the durable recovery path: it outlives every machine, is reachable from any device including a live ISO, and is the credential that authorizes adding a new `Host` as a recipient.
|
||||||
|
|
||||||
|
A **host identity** — a dedicated age key on each machine's encrypted root — is a recipient of only that machine's own secrets plus the shared file.
|
||||||
|
It is generated on the machine, never leaves it, and is deliberately not derived from the SSH host key, which is what frees the SSH host keys to become secrets in their own right.
|
||||||
|
|
||||||
|
Secrets are `sops`-encrypted into this same public repo.
|
||||||
|
The ciphertext is safe to publish, and the only artifact that would not be — the admin private identity — is never committed at all.
|
||||||
|
No second repository is introduced.
|
||||||
|
|
||||||
|
The first pass moves the login password off its hand-set value and makes `neogaia`'s SSH host keys stable across reimages.
|
||||||
|
That exercises both decryption paths — the early one that runs before user creation, and the ordinary activation one — proving the machinery end to end on the two secrets that are actually needed today.
|
||||||
|
|
||||||
|
## User Stories
|
||||||
|
|
||||||
|
1. As the operator, I want my login password declared as an encrypted secret rather than typed into a running machine, so that it is reproducible and survives a reimage like everything else in the flake.
|
||||||
|
2. As the operator, I want secrets encrypted into the existing public repo rather than a separate private one, so that there is one repository to clone and no bootstrap credential is needed to reach my own configuration during an install.
|
||||||
|
3. As the operator, I want an admin identity held in my password manager and never committed, so that nothing brute-forceable is published and I can recover every machine from a device I have never used before.
|
||||||
|
4. As the operator, I want each `Host` to hold its own age identity, so that a compromised server cannot decrypt my laptop's secrets.
|
||||||
|
5. As the operator, I want a shared secrets file alongside per-`Host` ones, so that material common to every machine is stored once rather than duplicated five times.
|
||||||
|
6. As the operator, I want my workstation to be a recipient of only its own secrets, so that the admin identity stays a break-glass credential rather than something sitting unlocked on a laptop.
|
||||||
|
7. As the operator, I want `neogaia`'s SSH host keys stored as secrets and restored at activation, so that reimaging the laptop does not invalidate its host identity or break `known_hosts` for clients.
|
||||||
|
8. As the operator, I want the secrets machinery to live in the `Skeleton` rather than behind an `enable` flag, so that it reads as plumbing every `Host` depends on rather than an optional feature.
|
||||||
|
9. As the operator, I want individual secrets declared next to the configuration that consumes them, so that a reader finds the secret where they find its use.
|
||||||
|
10. As the operator, I want a mistyped secret name or a missing secrets file to fail the build, so that errors surface at `nix flake check` rather than at boot.
|
||||||
|
11. As the operator, I want the procedure for provisioning a new `Host`'s identity written down, so that installing the desktop and the servers does not require rederiving the key ceremony under pressure.
|
||||||
|
12. As the operator, I want the recovery path documented for a machine whose identity was provisioned wrongly, so that a failed first boot is a known procedure rather than an improvised one.
|
||||||
|
13. As the operator, I want the editing workflow documented, so that I know which secrets I can change from my laptop and which require unlocking the admin identity.
|
||||||
|
|
||||||
|
## Implementation Decisions
|
||||||
|
|
||||||
|
**Identity topology**
|
||||||
|
|
||||||
|
- Two tiers of recipient: one admin identity, plus one identity per `Host`.
|
||||||
|
Every secrets file is encrypted to admin and to whichever `Host`s legitimately read it.
|
||||||
|
- The admin identity is stored as a secure note in Proton Pass and is never committed in any form.
|
||||||
|
Only its public recipient appears in the repo.
|
||||||
|
No passphrase-encrypted copy is committed: the vault already provides passphrase protection with rate limiting, whereas a committed copy would be offline-brute-forceable by anyone who clones the repo, indefinitely.
|
||||||
|
- Each `Host` identity is a dedicated age key on the LUKS-encrypted root, generated on that machine and never transmitted.
|
||||||
|
It is *not* derived from the SSH host key.
|
||||||
|
Decoupling them is what allows the SSH host keys to be secrets themselves; deriving one from the other would be circular.
|
||||||
|
- An admin recipient on every file is structurally required, not a convenience.
|
||||||
|
A file readable only by its own `Host` becomes permanently unrecoverable the moment that machine is wiped, and adding any new recipient must be done by someone who can already decrypt.
|
||||||
|
|
||||||
|
**Repository layout**
|
||||||
|
|
||||||
|
- One repository, public, unchanged.
|
||||||
|
A private repository was considered and rejected: cloning it requires credentials that would themselves become bootstrap material at install time, reintroducing the hand-carried secret the design otherwise eliminates, in exchange for protecting content that is already safe to publish.
|
||||||
|
- A `sops` configuration file and a secrets directory at the repo root.
|
||||||
|
- One secrets file per `Host`, encrypted to admin plus that `Host`.
|
||||||
|
- One shared secrets file encrypted to admin plus every `Host`.
|
||||||
|
- `neogaia` is a recipient of its own file and the shared file only.
|
||||||
|
Editing another machine's secrets requires unlocking the admin identity for that session, which is the intended friction.
|
||||||
|
|
||||||
|
**Secrets in this pass**
|
||||||
|
|
||||||
|
- The primary user's password hash lives in the shared file, consumed through `hashedPasswordFile`.
|
||||||
|
It is marked as needed for users, which makes `sops-nix` decrypt it in an earlier activation stage than ordinary secrets, before accounts are created.
|
||||||
|
This is the one ordering subtlety in the design and is the reason the `Host` identity must sit on the root filesystem rather than anywhere later-mounted.
|
||||||
|
- Storing the password hash in the shared file rather than per-`Host` is deliberate.
|
||||||
|
The same password will be used on every machine, so duplicating the identical hash across per-`Host` files would not reduce what an attacker learns — it would only make rotation a five-file edit.
|
||||||
|
- `neogaia`'s SSH host **private** keys live in its own `Host` file, with `sshd`'s generated host keys disabled and pointed at the decrypted paths instead.
|
||||||
|
- SSH host **public** keys are committed in plaintext.
|
||||||
|
They are not secret — publishing them is their function — and encrypting them would impose a re-key cycle every time one changes.
|
||||||
|
|
||||||
|
**Placement in the flake**
|
||||||
|
|
||||||
|
- The machinery goes in the `Skeleton` as unconditional configuration, not behind an `enable` flag.
|
||||||
|
This is a deliberate departure from the `Enable convention`, on the same grounds as the overlays and the flakes settings: every `Host` will carry secrets, so the flag would be permanently `true`, and the plumbing is not a feature a `Host` chooses.
|
||||||
|
- The `Skeleton` carries only the machinery — the flake input, the identity file location, and the default secrets file.
|
||||||
|
Individual secrets are declared wherever they are consumed, so the password secret sits beside the user declaration it feeds and the SSH host keys beside the `sshd` configuration.
|
||||||
|
- `sops-nix` is added as a flake input following the base `nixpkgs`.
|
||||||
|
|
||||||
|
**Operational procedures**
|
||||||
|
|
||||||
|
- For `neogaia`, which is already installed and running, provisioning happens live: generate the identity on the machine, add its recipient, re-key the affected files with the admin identity, rebuild.
|
||||||
|
No reimage and no live ISO are involved.
|
||||||
|
- For a `Host` that does not yet exist, provisioning happens on the live ISO *before* the install: generate the identity, add its recipient, re-key, write the identity onto the target root, then install.
|
||||||
|
The first boot then has everything it needs and cannot fail for want of a key.
|
||||||
|
The install already builds from a local clone, so no push is required mid-procedure; the recipient change is committed afterward.
|
||||||
|
- Both procedures, the editing workflow, and the live-ISO recovery path are documented in the existing install document rather than a new one.
|
||||||
|
|
||||||
|
**Decision record**
|
||||||
|
|
||||||
|
- A new ADR supersedes `ADR 0001`, which is marked superseded.
|
||||||
|
`ADR 0001`'s choice of `sops-nix` over `agenix` still holds and carries forward in a sentence, but its key-derivation mechanism is replaced and its stated consequence — that each new `Host` registers its SSH host public key as a recipient — is inverted, since SSH host keys are now secrets rather than the root of trust.
|
||||||
|
|
||||||
|
## Testing Decisions
|
||||||
|
|
||||||
|
- A good test here asserts externally-observable build and activation behaviour, not the internals of `sops-nix`.
|
||||||
|
Nothing in this feature is our own logic to unit-test; it is configuration wiring, and the meaningful assertions are that the whole `Host` still evaluates and that the secrets actually materialize on a real machine.
|
||||||
|
- **Primary seam (required):** `nix flake check` building the `neogaia` system toplevel, the same seam the laptop MVI established.
|
||||||
|
It carries real weight for this feature rather than merely compiling: `sops-nix` validates secrets files at evaluation time by default, so a missing file, a file that is not valid `sops` output, or a declared secret whose key is absent from it all fail the build.
|
||||||
|
Mistyped secret names surface here rather than at boot.
|
||||||
|
- **Confirmation (manual):** a real activation on `neogaia`.
|
||||||
|
This is what proves decryption itself — that the `Host` identity is readable at the right stage, that secrets appear with the declared ownership and mode, that `sshd` adopts the restored host keys, and that login works against `hashedPasswordFile`.
|
||||||
|
It cannot be automated without a machine that holds a real identity, and is treated like the reimage in the laptop MVI: manual by nature.
|
||||||
|
- No new seams are introduced.
|
||||||
|
The existing whole-`Host` build remains the highest available point, and the config-merge model makes it the meaningful unit.
|
||||||
|
- Prior art: the toplevel-build check established by the laptop MVI, already wired as the flake's `checks` output.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- The Anthropic API key.
|
||||||
|
It is the natural next secret, but it is consumed as an environment variable rather than a file path, and conflating that shape with the bootstrap work would obscure both.
|
||||||
|
- The WireGuard/ProtonVPN key, which has no `Module` to consume it yet.
|
||||||
|
- Declarative wifi credentials.
|
||||||
|
NetworkManager profile secrets are fiddly and joining from the console currently works.
|
||||||
|
- Fleet-wide SSH host verification.
|
||||||
|
With one `Host` there is nothing to verify against, and the choice of whether to identify machines by name or address should be made when a second machine exists and the answer is known rather than guessed.
|
||||||
|
- Provisioning any identity for a `Host` that does not exist yet.
|
||||||
|
The procedure is documented; no key is generated for `zeus` or the servers.
|
||||||
|
- Rotating the LUKS passphrase or coupling it to secret decryption.
|
||||||
|
- Hardware-token identities.
|
||||||
|
A YubiKey can be added later as an additional admin recipient without changing any decision here.
|
||||||
|
- Any change to how the flake is fetched during an install.
|
||||||
|
|
||||||
|
## Further Notes
|
||||||
|
|
||||||
|
- **The lockout risk is confined to fresh installs.**
|
||||||
|
On `neogaia` the transition is safe: if activation fails, the rebuild fails and the running generation persists with the existing hand-set password intact.
|
||||||
|
A machine being installed for the first time has no such fallback, because the password now arrives only from a decrypted secret — which is exactly why its identity is provisioned before the first boot rather than after it.
|
||||||
|
- **The admin identity is the single point of recovery**, and its durability is now a property of Proton Pass rather than of any machine or repository.
|
||||||
|
Losing the vault without a backup means losing the ability to add recipients or recover a wiped `Host`, even though every currently-running machine keeps working from its own identity.
|
||||||
|
- **Stable SSH host keys were nearly given up** in favour of deriving identities from them, and were recovered by inverting the dependency.
|
||||||
|
The rule that made it work generalizes: exactly one secret per machine must arrive out of band, and making that one thing a purpose-built key rather than a repurposed one keeps everything else declarable.
|
||||||
|
- **Adding a `Host` is a re-key, not a re-encrypt.**
|
||||||
|
A `sops` file holds a single data key encrypted once per recipient, so registering a new machine rewrites only that metadata, and the cost stays constant as the fleet grows to five.
|
||||||
|
- **This is what `ADR 0001` chose `sops-nix` for.**
|
||||||
|
The shared-plus-per-`Host` file split with overlapping recipients is precisely the multi-recipient, grouped-file model that decided against `agenix`; the topology change replaces how identities are obtained, not why the tool was picked.
|
||||||
35
.claude/tasks/0010-sops-skeleton-and-password.md
Normal file
35
.claude/tasks/0010-sops-skeleton-and-password.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
---
|
||||||
|
spec: sops-secrets
|
||||||
|
---
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
The tracer bullet for encrypted secrets: a working two-tier age identity model, with the primary user's login password arriving as a decrypted secret rather than a value typed into a running machine.
|
||||||
|
|
||||||
|
Establish the two identities the model rests on.
|
||||||
|
An admin identity is generated and stored as a secure note in the operator's password manager; only its public recipient ever appears in the repo, and no copy of the private half is committed in any form.
|
||||||
|
A host identity is generated on `neogaia` itself, onto its encrypted root, never transmitted, and deliberately not derived from the machine's SSH host key.
|
||||||
|
|
||||||
|
Commit a sops configuration naming both recipients and a shared secrets file encrypted to admin plus `neogaia`, holding the primary user's password hash.
|
||||||
|
The hash lives in the shared file rather than a per-host one because the same password is used on every machine, so per-host copies would only make rotation a multi-file edit.
|
||||||
|
|
||||||
|
Wire the tooling into the flake as unconditional plumbing in the shared base config — not behind an enable flag, on the same grounds as the overlays and the flakes settings.
|
||||||
|
The base config carries only the machinery: the flake input, the identity file location, and the default secrets file.
|
||||||
|
The password secret itself is declared beside the user declaration it feeds, so a reader finds the secret where they find its use.
|
||||||
|
|
||||||
|
The password secret must be marked as needed for user creation, which decrypts it in an earlier activation stage than ordinary secrets.
|
||||||
|
That ordering is why the host identity has to sit on the root filesystem rather than anywhere mounted later.
|
||||||
|
|
||||||
|
The transition is safe on `neogaia`: if activation fails the rebuild fails and the running generation persists with its existing hand-set password intact.
|
||||||
|
|
||||||
|
## 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
|
||||||
27
.claude/tasks/0011-neogaia-ssh-host-keys.md
Normal file
27
.claude/tasks/0011-neogaia-ssh-host-keys.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
spec: sops-secrets
|
||||||
|
blocked-by: 0010-sops-skeleton-and-password
|
||||||
|
---
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
`neogaia`'s SSH host keys become secrets, so reimaging the laptop no longer invalidates its host identity or breaks `known_hosts` for every client that has ever connected to it.
|
||||||
|
|
||||||
|
Introduce a per-host secrets file for `neogaia`, encrypted to the admin identity plus `neogaia` alone — the first file in the repo that is not readable by the whole fleet, and the thing that keeps a compromised machine from decrypting another's material.
|
||||||
|
The host's SSH **private** keys go in it.
|
||||||
|
|
||||||
|
The host **public** keys are committed in plaintext.
|
||||||
|
Publishing them is their function, and encrypting them would impose a re-key cycle every time one changes.
|
||||||
|
|
||||||
|
Stop the SSH daemon generating its own host keys and point it at the decrypted paths instead.
|
||||||
|
These secrets decrypt in the ordinary activation stage rather than the early pre-user one, so this slice exercises the second of the two decryption paths.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] A secrets file for `neogaia` exists, encrypted to the admin identity and `neogaia` only — not to any other recipient
|
||||||
|
- [ ] `neogaia`'s SSH host private keys are stored in it
|
||||||
|
- [ ] The corresponding host public keys are committed in plaintext
|
||||||
|
- [ ] The SSH daemon no longer generates its own host keys and reads the decrypted paths
|
||||||
|
- [ ] The host key secrets are declared beside the SSH daemon configuration that consumes them
|
||||||
|
- [ ] `nix flake check` builds the `neogaia` toplevel
|
||||||
|
- [ ] Manual confirmation: after activation the secrets materialize with the declared ownership and mode, the daemon adopts the restored keys, and the host fingerprint presented to a client is unchanged
|
||||||
37
.claude/tasks/0012-secrets-operations-docs.md
Normal file
37
.claude/tasks/0012-secrets-operations-docs.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
spec: sops-secrets
|
||||||
|
blocked-by: [0010-sops-skeleton-and-password, 0011-neogaia-ssh-host-keys]
|
||||||
|
---
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
A thorough revision of the install document, not an appendix to it.
|
||||||
|
|
||||||
|
The existing procedure is built around a login password set by hand through `nixos-enter` after the install and never committed.
|
||||||
|
That step no longer exists, so the parts of the document that describe it are wrong rather than merely incomplete: the framing that names two hand-entered secrets, the step that sets the bootstrap password, the reboot step's instruction to log in with it, and the closing follow-up section — which additionally describes the superseded key-derivation mechanism as the reason sops wiring cannot happen during an install.
|
||||||
|
The LUKS passphrase remains the one secret genuinely entered by hand, and the revised document should say so plainly.
|
||||||
|
|
||||||
|
The install ordering inverts.
|
||||||
|
A host's identity is now provisioned and registered *before* its first boot, because the login password arrives only from a decrypted secret and there is no fallback credential — a first boot without a registered identity has no way in.
|
||||||
|
The document should carry that as the reason, since it is the whole point of the reordering.
|
||||||
|
|
||||||
|
Cover four procedures:
|
||||||
|
|
||||||
|
- Provisioning a host that is already installed and running, done live on the machine: generate the identity, add its recipient, re-key the affected files with the admin identity, rebuild. No reimage, no live ISO.
|
||||||
|
- Provisioning a host that does not yet exist, done on the live ISO before the install: generate the identity, add its recipient, re-key, write the identity onto the target root, then install. The install builds from a local clone, so no push is required mid-procedure; the recipient change is committed afterward.
|
||||||
|
- The editing workflow: which secrets the workstation can change on its own, and which require unlocking the admin identity for the session. That friction is intended, not an oversight.
|
||||||
|
- Recovery from a live ISO for a machine whose identity was provisioned wrongly, so a failed first boot is a known procedure rather than an improvised one.
|
||||||
|
|
||||||
|
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
|
||||||
17
CLAUDE.md
17
CLAUDE.md
@@ -24,16 +24,13 @@ The domain model (Host, Module, Skeleton, Auto-loader, Enable convention, overla
|
|||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- Nix on the dev host needs experimental features passed per-command.
|
- This repo is developed on `neogaia`, which now runs the NixOS it builds.
|
||||||
This repo is developed on `neogaia` while it still runs **CachyOS** (the migration target), where Nix is the distro package at `/usr/bin/nix` in multi-user daemon mode.
|
Flakes and the chaotic substituter come from this flake's own `nix.settings`, so no `NIX_CONFIG` export or per-command `--extra-experimental-features` is needed, and building a toplevel with `boot.kernelPackages = linuxPackages_cachyos` fetches the kernel from `nyx-cache` rather than compiling it.
|
||||||
The system `/etc/nix/nix.conf` does not enable flakes, so export `NIX_CONFIG="experimental-features = nix-command flakes"` (or pass `--extra-experimental-features 'nix-command flakes'`) for every command.
|
Both were true only while the machine still ran CachyOS against a distro Nix daemon.
|
||||||
- The dev user is a non-trusted daemon client (`nix store info` reports `Trusted: 0`).
|
- The substituters a `nix build` fetches from are the **daemon's** (`/etc/nix/nix.conf`), *not* the `nix.settings` of the config being built — those only govern the built system.
|
||||||
You cannot add substituters from the CLI, so rely on what the flake/config declares (e.g. the chaotic cache is wired by the chaotic module, not a CLI flag).
|
The two coincide here because the dev host runs this flake; they diverge on any machine that does not.
|
||||||
Caveat that bites when a Host actually selects the CachyOS kernel: the substituters a `nix build` fetches from are the **daemon's** (`/etc/nix/nix.conf`), *not* the `nix.settings` of the config being built — those only govern the built system.
|
- Git identity is not configured anywhere yet — no `programs.git` in the flake and no `~/.gitconfig`, so `git commit` fails with "Author identity unknown".
|
||||||
This dev host's `/etc/nix/nix.conf` has no `substituters`/`trusted-substituters` lines, so building a toplevel whose `boot.kernelPackages` is `linuxPackages_cachyos` compiles the kernel (and rustc bootstrap, etc.) from source instead of hitting `nyx-cache`.
|
History uses `alexion <contact@alexion.dev>`; pass it per-commit with `git -c user.name=… -c user.email=…` rather than writing config outside the flake.
|
||||||
To build such a Host here, first add `extra-substituters = https://nyx-cache.chaotic.cx/` and `extra-trusted-public-keys = nyx-cache.chaotic.cx:dJxTrgMC3V3cFfyIiBQDQorG6k1LsqurH/srpMSq7qk=` to `/etc/nix/nix.conf` (sudo) and `sudo systemctl restart nix-daemon`.
|
|
||||||
`nix eval` of the kernel version does *not* trigger this — only a real build does.
|
|
||||||
- If `/nix/store` is missing or `nix-daemon` is inactive after a fresh Nix install, initialise it with `sudo systemd-tmpfiles --create nix-daemon.conf && sudo systemctl enable --now nix-daemon.socket`.
|
|
||||||
- The primary build/verify seam for any Host is `nix flake check`, which builds `checks.x86_64-linux.<host>` (the system toplevel); cheap targeted checks use `nix eval .#nixosConfigurations.<host>.config...`.
|
- The primary build/verify seam for any Host is `nix flake check`, which builds `checks.x86_64-linux.<host>` (the system toplevel); cheap targeted checks use `nix eval .#nixosConfigurations.<host>.config...`.
|
||||||
- chaotic-nyx must **not** follow our `nixpkgs`, and its packages are built against chaotic's own pinned nixpkgs (its overlay defaults to `onTopOf = "flake-nixpkgs"`, the cache-friendly path).
|
- chaotic-nyx must **not** follow our `nixpkgs`, and its packages are built against chaotic's own pinned nixpkgs (its overlay defaults to `onTopOf = "flake-nixpkgs"`, the cache-friendly path).
|
||||||
That is what lets the `nyx-cache.chaotic.cx` binary cache hit instead of compiling the CachyOS kernel from source; the tradeoff is that chaotic packages do not see our `unstable`/`stable` overlays.
|
That is what lets the `nyx-cache.chaotic.cx` binary cache hit instead of compiling the CachyOS kernel from source; the tradeoff is that chaotic packages do not see our `unstable`/`stable` overlays.
|
||||||
|
|||||||
@@ -39,5 +39,4 @@
|
|||||||
|
|
||||||
time.timeZone = "America/New_York";
|
time.timeZone = "America/New_York";
|
||||||
i18n.defaultLocale = "en_GB.UTF-8";
|
i18n.defaultLocale = "en_GB.UTF-8";
|
||||||
console.keyMap = "us";
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,14 @@ in
|
|||||||
];
|
];
|
||||||
environment.systemPackages = [ pkgs.git ];
|
environment.systemPackages = [ pkgs.git ];
|
||||||
|
|
||||||
|
# Caps Lock is a second Escape; Shift+Caps Lock still toggles Caps Lock.
|
||||||
|
services.xserver.xkb.layout = "us";
|
||||||
|
services.xserver.xkb.options = "caps:escape_shifted_capslock";
|
||||||
|
|
||||||
|
# Compile the console keymap from the layout above, so the remap holds on a
|
||||||
|
# bare TTY and not only under a graphical session.
|
||||||
|
console.useXkbConfig = true;
|
||||||
|
|
||||||
# Primary user, in the wheel group. No password set here.
|
# Primary user, in the wheel group. No password set here.
|
||||||
users.users.${user.name} = {
|
users.users.${user.name} = {
|
||||||
isNormalUser = true;
|
isNormalUser = true;
|
||||||
|
|||||||
Reference in New Issue
Block a user