Supersede ADR 0001's SSH-derived key mechanism with an admin identity held outside the repo plus a per-host identity on each encrypted root. Decoupling the two is what lets the SSH host keys become secrets themselves rather than the root of trust they were. Add the spec, the three implementing tasks, and the glossary terms the breakdown speaks in.
14 KiB
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
- 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.
- 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.
- 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.
- As the operator, I want each
Hostto hold its own age identity, so that a compromised server cannot decrypt my laptop's secrets. - As the operator, I want a shared secrets file alongside per-
Hostones, so that material common to every machine is stored once rather than duplicated five times. - 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.
- 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 breakknown_hostsfor clients. - As the operator, I want the secrets machinery to live in the
Skeletonrather than behind anenableflag, so that it reads as plumbing everyHostdepends on rather than an optional feature. - 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.
- 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 checkrather than at boot. - 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. - 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.
- 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 whicheverHosts 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
Hostidentity 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
Hostbecomes 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
sopsconfiguration file and a secrets directory at the repo root. - One secrets file per
Host, encrypted to admin plus thatHost. - One shared secrets file encrypted to admin plus every
Host. neogaiais 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 makessops-nixdecrypt 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 theHostidentity must sit on the root filesystem rather than anywhere later-mounted. - Storing the password hash in the shared file rather than per-
Hostis deliberate. The same password will be used on every machine, so duplicating the identical hash across per-Hostfiles 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 ownHostfile, withsshd'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
Skeletonas unconditional configuration, not behind anenableflag. This is a deliberate departure from theEnable convention, on the same grounds as the overlays and the flakes settings: everyHostwill carry secrets, so the flag would be permanentlytrue, and the plumbing is not a feature aHostchooses. - The
Skeletoncarries 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 thesshdconfiguration. sops-nixis added as a flake input following the basenixpkgs.
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
Hostthat 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 ofsops-nixoveragenixstill holds and carries forward in a sentence, but its key-derivation mechanism is replaced and its stated consequence — that each newHostregisters 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 wholeHoststill evaluates and that the secrets actually materialize on a real machine. - Primary seam (required):
nix flake checkbuilding theneogaiasystem toplevel, the same seam the laptop MVI established. It carries real weight for this feature rather than merely compiling:sops-nixvalidates secrets files at evaluation time by default, so a missing file, a file that is not validsopsoutput, 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 theHostidentity is readable at the right stage, that secrets appear with the declared ownership and mode, thatsshdadopts the restored host keys, and that login works againsthashedPasswordFile. 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-
Hostbuild 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
checksoutput.
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
Moduleto 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
Hostthere 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
Hostthat does not exist yet. The procedure is documented; no key is generated forzeusor 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
neogaiathe 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
Hostis a re-key, not a re-encrypt. Asopsfile 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 0001chosesops-nixfor. The shared-plus-per-Hostfile split with overlapping recipients is precisely the multi-recipient, grouped-file model that decided againstagenix; the topology change replaces how identities are obtained, not why the tool was picked.