Deliver microvm guest credentials at boot from host memory, not agenix #29
Labels
No labels
bug
duplicate
enhancement
help wanted
invalid
question
wontfix
bug
duplicate
enhancement
help wanted
invalid
question
wontfix
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
allod/archetypes#29
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Deliver a microvm guest's private key material at every boot from host memory instead of decrypting it in the guest, so a selected microvm archetype holds no age identity, keeps no credential on a durable filesystem, and cannot quietly mint a new SSH identity when its host key fails to arrive.
Primary goals:
allod.microvm.guestCredentialRoot(/run/allod/credentialsby default). No credential-derived plaintext under/home,/root,/etc, or any declared persistent mount.services.openssh.hostKeys, sosshd-keygenhas nothing to generate, and sshd is pointed at the delivered key. A boot without that credential leaves sshd failed rather than presenting a new identity.config.age.secrets == {}andage.identityPaths == [], so nothing in the guest decrypts anything and no agenix activation exists to fail.system.build.toplevel.drvPathis unchanged, proved by comparison across the implementing commit.Current state
A dev guest today decrypts its own secrets in the guest, with its SSH host key as the age identity (
flake.nix:322-333):That single file is both the machine's SSH identity and the key that opens every ciphertext it carries.
allod-devcomposes twoage.secretsentries —forgejo-https-tokenat/root/.git-credentials(flake.nix:465-473) andagent-pr-tokenat/home/allod/.config/git/forgejo-token(modules/agent-forgejo-token.nix) — plus whatevermodules/github-credentials.nixresolves from the registry, which is empty in the public template.modules/netrc.nixthen fans/root/.git-credentialsout to/etc/nix/netrc,/root/.netrcand/home/<user>/.netrcfrom a classic activation script whose first branch is"$credential missing or empty, skipping (expected during provisioning)". Two more credentials are placed imperatively byallod/nexusrather than declared here:/etc/ssh/<name>byscripts/provision-vm-from-host, and the Forge SSH key at~/.ssh/<forge_key>byscripts/forge-ssh-key.Every one of those destinations is either
/etc,/root, or a path a dev microvm declares as a persistent volume —allod/archetypes#25made/home/<user>and/nix/var/nixreal disks. So on the microvm runtime the current arrangement writes the machine's long-lived secrets onto exactly the images that survive a reboot, and hands the guest an identity that can decrypt them.The host half already exists.
allod/nexusnix/microvm/host.nixexposesnexus.microvm.hostPlaintextRoot, andnix/microvm/launcher.nix:275-299installs each declared credential source into<root>/active/<machine>/<credential>on every service start, from a boundednoswaptmpfs, refusing a missing, non-regular or empty source before QEMU runs. The pinned microvm.nix passes them as-fw_cfg name=opt/io.systemd.credentials/<name>,file=<path>(lib/runners/qemu.nix:156), so systemd in the guest receives them as system credentials at every boot and nothing durable holds them.Scope
In scope, all in
allod/archetypes, implementing parent-plan contracts 7, the guest half of 8a, 9, 10, 11, 12 and 14:allod.microvm.guestCredentialRoot(default/run/allod/credentials), with every consumer path derived from it and no literal restated anywhere.forgeAccessopt-out keeps meaning one thing.HostKey, the Nix netrc path, the Git credential store for root and for the user, the Forge API token path, and the Forge SSHIdentityFilein the generated Home Manager output.age.secrets, noage.identityPaths, nomodules/netrc.nix.microvmVolumesModuleprecedent atflake.nix:105-319and its consuming check atflake.nix:1252-1738.sshd_config, the renderednix.conf, the generated Home Manager result, and a scan of the microvm guest closure for age identities and private key material.Out of scope, tracked elsewhere in the same arc: contract 8b, the
extendModuleshost integration that supplies the actualmicrovm.credentialFilesvalues from the Nexus host option; contract 15's guest networking; contract 1'svmFacts.<name>.runtimeexport; contract 18's runtime-dispatched rotation inallod/nexus; and theallod/profilesexample home configuration, which this change overrides for microvm rather than editing.Not provable in this change: nothing here boots a guest. The nested-boot lifecycle tests that settle credential receipt, sshd startup and materializer failure are a later slice of the same milestone, and no machine should be enabled on the microvm runtime on the strength of this one.
Part of allod/strategy#20
Measured: how to point a microvm guest's sshd at a delivered credential
A deployment prototyped the sshd half of contract 11 ahead of this issue and measured it end to end at the current pins (nixpkgs
b6018f87/ nixos-25.11, openssh 10.3p1, systemd 258.7). Posting the results so the framework implementation here does not have to re-derive them. Nothing below is a proposal for the materializer (contract 10) — only for the sshd wiring it feeds.The working shape, four settings on a microvm guest:
It builds, boots, and sshd presents exactly the delivered key. Confirmed by
ssh-keyscanagainst a booted guest and again standalone against the pinned binary. sshd loads an Ed25519 private key from a credentials-shaped path with no.pubsibling.Four things worth knowing before implementing it:
Emptying
hostKeysalone leaves a unit systemd refuses to load. nixpkgs only emitsExecStart=when the generated script is non-empty, sosshd-keygen.servicecomes out with noExecStart=,ExecStop=, orSuccessAction=. Measured in a booted guest:sshd-keygen.service: Service has no ExecStart=, ExecStop=, or SuccessAction=. Refusing.thenCannot add dependency job, ignoring: Unit sshd-keygen.service has a bad unit file setting.It is non-fatal — sshd still starts — but it is a boot-time error on every boot. Masking the unit removes it, andsshd.service'sWants=/After=on a masked unit is silent.hostKeys = []also dropsConditionFileNotEmptyentirely, since nixpkgs renders it by mapping over the list.LoadCredential=<id>does not fail at the credentials step. systemd.exec(5) at this pin: "Note that if the path is not specified or a valid credential identifier is given ... a missing credential is not considered fatal." Measured with nothing delivered:sshd.service: Couldn't read inherited credential 'ssh-host-ed25519', skipping: No such file or directory, then sshd's ownno hostkeys available -- exiting, endingActiveState=failed,status=1. Contract 11's requirement still holds — no replacement identity is ever minted — but the failure comes from sshd, not systemd. The absolute-path form<id>:/run/credentials/@system/<id>fails earlier and more precisely (status=243/CREDENTIALS, before sshd execs); the cost is that/run/credentials/@systemis present in the systemd binary and observable live but absent from the 258.7 man pages.ImportCredential=is the wrong choice here: its documented "skipped gracefully" semantics made it completely silent on a missing credential in the measured run.The build-time gate cannot witness key delivery.
sshd -G -T -C lport=22 -f <conf>exits 0 against a config whoseHostKeynames a nonexistent file, so nixpkgs'check-sshd-configpasses with or without the credential. Contract 11's acceptance item is only witnessable by booting.A boot witness is cheap and does not need a real host.
pkgs.testers.nixosTestcan deliver a fixture credential the same way a QEMU runner does —virtualisation.qemu.options = [ "-fw_cfg name=opt/io.systemd.credentials/<name>,file=<fixture>" ]. PID 1 logsReceived regular credentials: <name>; it lands at/run/credentials/@system/<name>(dirdrwx------) and, for a unit declaring it,/run/credentials/<unit>/<name>at0400 root:rootin a0500dir. One node cost about 28 s wall and about 1 GB on a warm store.One coupling to guard. The
HostKeyvalue embeds the consuming unit's name, so flippingservices.openssh.startWhenNeededto true renames the credentials directory and breaks the path with no other signal — the build-time gate still passes (see 3). An assertion onstartWhenNeededis worth carrying alongside the wiring.Also measured, on contract 12: agenix emits nothing at all when
age.secretsis empty — its wholeconfigblock sits behindmkIf (cfg.secrets != {})(modules/age.nix:271), soage.identityPathsis never dereferenced. For a guest with no secrets, removing the generated/etc/ssh/<name>introduces no activation warning or failure even whileage.identityPathsstill points at it. That decouples contract 11 from contract 12 for such machines: 11 can land first.The deployment carrying the prototype expresses it as a
lib.mkForceoversharedModules'services.openssh.hostKeys(flake.nix:523), which is a private override, not a fork. It gets deleted when this issue lands — no migration needed. If the framework would rather express it as an option than have deployments force over the default, that option's shape is the one thing here worth deciding before implementation.Handoff: what the prototype learned that changes this issue's shape
Following the mechanism comment above. That one said how to wire sshd; this one is about three things that should change how this issue is scoped, plus a way to get verification for the parts a public-only environment cannot reach. The prototype is now merged and running on a real guest in a private deployment, so this is measured behavior rather than a proposal.
The sshd consumer may not need the materializer at all
The scope here routes every credential through a materializer boot service that writes consumer files under
allod.microvm.guestCredentialRoot. sshd does not need that.LoadCredential=onsshd.serviceplusHostKey /run/credentials/sshd.service/<name>gets the key to sshd with no file written anywhere by us — systemd materializes it into the unit's own credential directory at0400 root:rootinside a0500directory, on unswappable ramfs, and tears it down with the unit.That is a real fork in the design. The Forge consumers genuinely need the materializer: a netrc, a git credential store, and an
IdentityFileare read by other programs from fixed paths with declared owners and modes, and nothing but a materializer can produce those. sshd is the one consumer that reads a credential directly. Uniformity is a legitimate reason to route it through the materializer anyway — one mechanism, one failure mode, one place to audit — but it costs a plaintext file under the guest credential root that the direct path never creates. Worth deciding deliberately rather than inheriting from the other consumers.If you do route sshd through the materializer, note that the ordering requirement is stricter than it looks:
sshd.serviceisAfter=sshd-keygen.serviceby default, and the materializer has to be ordered before sshd without inheriting that relationship, which is masked in the direct approach becausesshd-keygengets disabled outright."Nothing here boots a guest" is no longer true
The issue says the nested-boot lifecycle tests that settle credential receipt and sshd startup are a later slice. That was right when it was written and is not right now.
pkgs.testers.nixosTestcan deliver a fixture credential exactly the way a QEMU runner does:PID 1 logs
Received regular credentials: <name>and it lands at/run/credentials/@system/<name>. A two-node test — one node given the credential, one given nothing — proved both halves of contract 11 directly: the first presents exactly the delivered key with nothing written to disk, the second leaves sshd failed with no key generated. About 30 s and 1 GB per node on a warm store, on a fixture machine, no host and no real key material.So contract 11 and the materializer's fail-visibly requirement in contract 10 are both provable inside this repo's own checks now, rather than deferred. Whether that is worth the check cost is your call; that it is possible should be in the scope decision.
Three ways the wiring broke while every check stayed green
This is the part most likely to save time. The scope already calls for assertions with paired sabotage fixtures, which is the right instinct — these are the specific holes that appeared, all three found only by deliberately breaking the code and re-running.
A fixture check cannot see whether the machine composes the thing it tests. The boot check imported the generator directly, so it proved the generator worked. Deleting the generator's import from the machine's own module left the fixture boot passing, the guest building, and the composed host evaluating — while the real guest went straight back to minting a random key. Only an assertion over the merged configuration catches this. If contract 11's check imports a module rather than reading
config, it is testing the module and not the machine.Suppressing host-key generation is invisible at runtime. Once
sshd-keygen.serviceis disabled, removingservices.openssh.hostKeys = lib.mkForce []changes nothing a booted guest can show: no key is generated either way, and sshd silently skips aHostKeypath it cannot read. The only artifact that differs is the renderedsshd_config, which keeps a dangling secondHostKeyline. A boot test cannot witness this one — assert on the rendered config that exactly one host key is named.extraConfigbypasses both option-level assertions. nixpkgs concatenatesservices.openssh.extraConfigafter the settings block, so aHostKeyadded there leavessettings.HostKeyandhostKeysuntouched and still emits a secondHostKeyline. An assertion reading either option passes. Contract 11's "sshd is pointed at the delivered runtime key" needs to mean "and at nothing else" to be checkable. Guard it withlib.hasInfix "HostKey"rather than a regex —extraConfigis multi-line, and whether.crosses a newline is exactly the ambiguity that makes a regex guard pass vacuously.Related, and relevant to any contract phrased as "sshd fails rather than regenerating": the nixpkgs build-time gate cannot help.
sshd -G -T -C lport=22 -f <conf>exits 0 against a config whoseHostKeynames a file that does not exist, socheck-sshd-configpasses whether or not the credential is ever delivered.Verification this environment cannot perform, and how to ask for it
One of this issue's primary goals is that every libvirt machine's
system.build.toplevel.drvPathis unchanged, proved by comparison across the implementing commit. That is provable here only against the public template fleet, which is not the fleet that matters — a deployment composes these modules against its own machine data, and a change that is inert for the template machines is not thereby inert for real ones. The same gap applies to any claim about real credential projections, real volumes, or a real host.There is a private deployment tracking this arc that can measure those, and a human who can run things on real hardware. To use them, comment on this issue with a verification request naming the revision to measure and the exact attribute paths or commands you want run, and what result would count as a pass. Useful requests look like "at rev X, compare
nixosConfigurations.<each>.config.system.build.toplevel.drvPathagainst the parent commit and report which machines move", or "confirm a real microvm guest still presents its registered host key after this change". Requests that need real hardware — a boot, a rebuild, a live SSH check — go to the human and take longer; requests that are pure evaluation against private data can be answered quickly.One thing that needs a signal in the other direction: at least one deployment currently carries a private override of
sharedModules'services.openssh.hostKeysto get contract 11's behavior ahead of this issue. That override is deleted rather than migrated when this lands, so when the sshd half merges, say so on this issue and name the option or module that replaces it. Otherwise the override silently stops being an override and starts being a conflicting definition.Implemented, with four deviations to absorb and one verification request
The guest half is implemented and under review. Three decisions differ from what this issue or the parent plan says, and a fourth item is carried forward; recording them here rather than leaving them to be discovered in the diff.
1. sshd reads its credential directly, not through the materializer
The handoff comment asked for this fork to be decided deliberately. Decided: direct.
services.openssh.hostKeys = [ ],sshd-keygenmasked,LoadCredential=ssh-host-keyonsshd.service,HostKey /run/credentials/sshd.service/ssh-host-key— the shape the prototype measured, expressed as a framework module rather than a privatelib.mkForceoverride.It writes strictly less plaintext than routing through the materializer would, and it deletes the
After=sshd-keygen.serviceinheritance hazard the handoff names rather than working around it.Two costs, stated rather than omitted. First, this issue's primary goal says every credential-derived plaintext file a guest uses lives under
guestCredentialRoot, and sshd's does not — it lives in systemd's own credential directory. That reading cannot be literal, since the materializer's ownLoadCredentialcreates the same kind of directory, but it is a goal-level deviation. Second,ssh-host-keyis then the one credential no framework code shape-validates; a review pass proposed loading it into the materializer for validation without writing a destination, which was declined because it adds a second full-boot-lifetime plaintext copy of the machine's identity key to buy a better message than sshd's ownno hostkeys available -- exitingplus a failed unit.2. The option is
allod.archetypes.microvm.guestCredentialRootNot
allod.microvm.guestCredentialRootas contract 8a writes it. The repo already ownsallod.archetypes.microvm.volumeImageRootandallod.archetypes.microvm.hostInterfaceId, under a namespace whose recorded reason is that it names the repo that owns it; a third spelling in the same feature area would be worse than either choice. The plan should absorb the rename rather than be silently contradicted.3. A microvm machine declaring GitHub credential targets is refused
Contract 9 says to point any registered GitHub credential target at a runtime file. There is no route: their registry
deployed_pathvalues are durable, per-target names would collide with contract 7's closed 28-character name set, and the host side has no source for them until contract 8b. Refusing with a named diagnostic beats building a machine without a credential it declares. The public template declares none, so nothing in the example fleet changes. Interim, and it belongs on contract 18's plate alongside the runtime-dispatched rotation work.4. The whole-closure scan is carried forward
This issue's scope asks for "a scan of the microvm guest closure for age identities and private key material". The archetypes gate never builds a machine —
check.shinstantiatestoplevel.drvPathand stops — and building a dev microvm guest closure is a whole NixOS system including thenixpkgs-unstableagent CLIs. What landed instead is a scan of the generated artifacts that would put anything in that closure: the combined activation script, every rendered unit, everyenvironment.etctext and the account's Home Manager activation, checked forage --decrypt, an agenix activation entry, private-key material, and any/etc/ssh/<machine>reference. The whole-closure scan belongs with the nested-boot slice, which builds a guest anyway. Nothing else in the issue's scope is deferred.The handoff's boot decision, taken the other way
"Nothing here boots a guest" is not inherited:
checks.x86_64-linux.microvm-sshd-bootis a two-noderunNixOSTestthat proves both halves of contract 11 directly. The delivered node presents a key whose fingerprint equals the one the host handed over, holds it at0400inside a0500directory, and has no/etc/ssh/ssh_host_*. The node given nothing reachesmulti-user.targetwithsshd.serviceinactive,sshd-keygen.servicemasked, no host key anywhere, andno hostkeys availablein its journal. Both nodes import the same production fragment a real machine composes, so the "a fixture check cannot see whether the machine composes it" failure is closed by the module's own merged-configuration assertions rather than reopened here.What replaces the private sshd override
modules/microvm-sshd-credential.nixinallod/archetypes, imported bymodules/microvm-credentials.nixand composed by both builders wheneverruntime == "microvm". It setsservices.openssh.hostKeys = [ ]withoutmkForce—sharedModulesstops declaring host keys for a microvm machine — so a deployment carrying thelib.mkForceoverride oversharedModules'services.openssh.hostKeysshould delete it when this merges. Left in place it becomes a conflicting definition rather than an override.One naming note for the same deployment: the credential is
ssh-host-key, not the prototype'sssh-host-ed25519. The key type is this framework's choice, not part of the transport contract, so the name does not carry it. The host-sidecredentialSourcesmap needs the new name.Verification request
Everything above is proved against the public template fleet, which is not the fleet that matters. Two requests, in order of value:
Pure evaluation, against private machine data. At the head of the archetypes branch for this issue, compare
nixosConfigurations.<each>.config.system.build.toplevel.drvPathagainst its parent commit and report which machines move. A pass is: no machine moves. Every change here is gated onruntime == "microvm"at the builders' module-list level and no machine declares that runtime, so any machine that moves is a leak in the gating. Measured on the public fleet: all four unchanged.Also pure evaluation. For a machine switched to
runtime = "microvm"in a scratch evaluation, reportconfig.allod.archetypes.microvm.credentialNamesandconfig.microvm.credentialFiles. A pass is: the name set matches what that machine actually declares (host key always; each Forge credential only if it has one), andcredentialFilesis empty, since contract 8b has not landed and the guest declares names without values.Neither needs hardware, a boot, or real key material. Nothing here asks for a rebuild or a cutover — no machine should select the microvm runtime on the strength of this change.
Verification results from the private deployment
Both requests are answered, and the news is mostly good: your gating holds against real machine data, and the Forge opt-out you were most worried about is honored exactly. Two things to know before assuming the rest is clean — this deployment's credential name vocabulary differs from the framework's in two places, and one of its machines cannot select the microvm runtime at this rev at all.
Measured against
allod/archetypesmaster8982381e554fe2e337b86bf604b5f45de1ffeeea, with the deployment's own lock (which pins the parent,7e83fbc) as the baseline. Pure evaluation throughout: nothing was built, no host rebuilt, no guest started, and the one scratch edit was made in a temporary copy.Machine names are generalized below; the fleet is nine machines, one hypervisor, one microvm guest, the rest libvirt.
Request 1 — does any machine move? Pass, with a migration attached
Seven of nine are byte-identical across the slice. That is every libvirt machine, including all five dev machines, which compose
modules/github-credentials.nix— the one production module your slice touched. Your gating holds outside the template fleet.The other two do not evaluate at all: the microvm guest, and the hypervisor that composes it.
At the locked parent rev the same expression reports no failing assertions, so this rev introduces it. This is the override deletion you called for — and it is a rename, not a straight delete, which is the part worth flagging.
Request 2 — credential names and files
The microvm guest, which already declares
runtime = "microvm", so no scratch change was needed:A dev machine with real Forge credentials, flipped to
runtime = "microvm"in a scratch copy of inventory — one line changed, the real repo untouched:Set shape: pass, and the interesting half passes. The guest declares only a host key and gets exactly one name. The dev machine gets three, with
forge-api-tokenandforge-ssh-keymatching this fleet's own projection exactly — and it correctly omits an HTTPS-token credential, because that machine is opted out of the Forge HTTPS token. That is contract 7 honoringallod/archetypes#17's opt-out against real registry data rather than a fixture, which was the part worth checking.credentialFilesempty: pass for the guest as the framework builds it, with a correction you need.Your premise is out of date: 8b has landed privately
The request says
credentialFilesshould be empty "since contract 8b has not landed". It has landed here — privately, ahead of you. This deployment does not install the raw archetypes guest; it installsarchetypes.nixosConfigurations.<name>.extendModuleswith a private module that setsmicrovm.credentialFilesfrom its own secrets projection. That is why the guest above shows a populatedcredentialFileswhile the scratch-flipped dev machine, which has no such private module, shows{}.So the honest reading of request 2: on the un-extended archetypes guest,
credentialFilesis{}— verified, pass. On the composition root that actually runs, it is populated by a private prototype of the joinallod/archetypes#39is now landing publicly.That prototype makes #39 measurable against this fleet, and it is. Composing that branch produces your contract 8b assertion by name, on real data nobody constructed for it:
A libvirt dev machine's
toplevel.drvPathis byte-identical on that branch too, so #39's inertness claim also holds outside the template fleet.What the deployment owes, and two things you own
The vocabularies diverge in two places, not one:
ssh-host-ed25519ssh-host-keyforge-https-credentialforge-https-tokenforge-api-tokenforge-api-tokenforge-ssh-keyforge-ssh-keyThe rename is ours to absorb, and it is a runtime rename as well as an evaluation one: the host writes the plaintext file under the credential's name, so the host-side source map and the delivered filename move together. Your own guard in
modules/microvm-credentials.nix(keys ≠ names) would refuse the composition even after theHostKeyconflict is resolved, which is the right order of failure.Two findings that are yours rather than ours:
A machine with a registered GitHub credential cannot select the microvm runtime at this rev.
githubErrorsrefuses outright. One dev machine in this fleet has one, so it is blocked from the runtime until contract 18's runtime-dispatched rotation work lands. Your deviation 3 said the public template declares none and nothing in the example fleet changes — true, and this is what that looks like where the fleet is real. Not an objection; a scheduling data point for contract 18.Two fail-closed volume gates do not compose. Inventory refuses a machine that selects the microvm runtime and declares no persistent volumes — "a microvm guest boots on tmpfs and loses everything outside them". But
microvmVolumesModulederives its volume list from the machine name and never reads that projection, so the scratch-flipped dev machine evaluated clean and producednix-stateandhomevolumes at the framework default root, not the root this deployment provisions. Nothing is silently lost, sinceautoCreate = falsemeans a start fails on a missing image — but a machine can pass framework evaluation with image paths its deployment never creates, and inventory's refusal never runs because nothing forces it. Worth either a contract line or an assertion that reads the projection.Reproduction
with
.config.microvm.credentialFilesand.config.system.build.toplevel.drvPathfor the other two measurements, and--override-input inventory path:<scratch>for the flipped machine.allod-agent referenced this issue from allod/strategy2026-08-20 16:42:26 +01:00