feat(guests): give guests only the host-decrypted secrets they name

Add a `secrets` placement option to the guest builder: a list of secret
names. The host declares each as one of its own sops secrets, so the host
is the sole decryptor from the sops files it already holds, and the
decrypted file is bind-mounted into the guest read-only at the same
`/run/secrets/<name>` path it occupies on a host. A service inside the
guest reads its credentials at a predictable location while the guest
carries no age key and decrypts nothing itself.

Ownership needs no new code: the container already runs in the host's uid
and gid space one to one, so the decrypted file's host owner is its owner
inside the guest.

Assert that no in-guest path is claimed by both a mount and a secret, so
the attribute-set merge fails loudly rather than resolving a collision
silently in the secret's favour.
This commit was merged in pull request #33.
This commit is contained in:
2026-07-25 20:46:08 -04:00
parent ab9b9e9f8f
commit d637d3e7f6
2 changed files with 94 additions and 4 deletions

View File

@@ -0,0 +1,40 @@
---
spec: guests
blocked-by: 0002-guest-walking-skeleton
---
## What to build
The Host-side placement that gives a Guest only the decrypted secrets it names, while no Guest ever holds a decryption key.
A Host sets `secrets`, the names of the secret files the Guest needs.
The Host is the sole decryptor, consistent with the host identity and the existing sops-nix setup: it decrypts, and the Guest receives only the specific named secret files, read-only bind-mounted in, with ownership aligned by the identity mapping.
A Guest holds no age key.
## Acceptance criteria
- [x] A Host setting `guests.<path>.secrets` gives the Guest exactly the named secret files and no others.
- [x] The named secrets are decrypted by the Host and bind-mounted into the Guest read-only, with ownership aligned by the identity mapping.
- [x] The Guest holds no age key and performs no decryption of its own.
- [x] A Host with a Guest that names secrets builds via `nix flake check`, and the resolved secret mounts are verifiable by `nix eval`.
## Implementation Notes
`secrets` is a placement option on the `guest` builder: a list of secret names.
For each name the builder declares `sops.secrets.<name>` on the host, so the host is the sole decryptor from the sops files it already holds, and bind-mounts the decrypted file read-only into the guest at the same `/run/secrets/<name>` path it occupies on the host.
A service inside the guest therefore reads its credentials at the location it would on a host, keeping a module placement-agnostic.
The guest holds no age key and declares no secrets of its own.
This is inherited, not added: `guest.nix` stands on `base.nix`, not on the host base `system.nix` that sets `sops.age.keyFile`.
Verified through `nixosConfigurations.neogaia.extendModules`: with `guests.sample.secrets = [ "alexion-password" ]`, the interior's `sops.age.keyFile` is null and its `sops.secrets` is empty, while the host declares the secret and the container's `bindMounts` carries exactly the one entry, read-only, keyed and sourced at the secret's path.
Ownership alignment needs no new code.
The container already runs with `privateUsers = "no"` (task 0006), so the host and guest share one uid and gid space, and the decrypted file's host owner is its owner inside the guest.
A secret defaults to `root:root` mode `0400`, so a guest service running as a non-root user needs the operator to set `sops.secrets.<name>.owner` on the host, which merges cleanly with the builder's stub declaration.
The `secrets` field stays a list of names per the spec, which treats the owner as host-set data.
A build-time assertion rejects an in-guest path claimed by both a `mounts` entry and a secret, since the two attribute sets merge and the collision would otherwise resolve silently in the secret's favour.
No host commits a `secrets` placement, mirroring the mounts, networking, and storage foundations (tasks 00030006): the only host, `neogaia`, is a laptop carrying no service that names one.
`nix flake check` passes on the committed tree, where the sample guest declares no secrets.
The full host toplevel was built with the sample guest naming `alexion-password` (a real key in `secrets/shared.yaml`); sops-nix validates at build time that each named key exists in the host's sops files, so a build with a name absent from those files fails clearly rather than at activation.
Real identity-mapped reads on the target host are verified manually, per the spec's testing decisions.