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

Merged
alexion merged 1 commits from task-0007-guest-secrets-placement into main 2026-07-25 21:07:13 -04:00
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.

58
lib.nix
View File

@@ -96,6 +96,33 @@ let
networked = cfg.vlan != null; networked = cfg.vlan != null;
# Host paths the operator maps into the guest, keyed by their in-guest path.
userMounts = lib.mapAttrs (_guestPath: m: {
inherit (m) hostPath;
isReadOnly = m.readOnly;
}) cfg.mounts;
# Each named secret bind-mounted read-only at the same `/run/secrets/<name>`
# path it holds on the host.
# No ownership is set here, since the container's one-to-one identity map
# carries the host file's owner through unchanged.
secretMounts = lib.listToAttrs (
map (
name:
let
path = config.sops.secrets.${name}.path;
in
lib.nameValuePair path {
hostPath = path;
isReadOnly = true;
}
) cfg.secrets
);
# An in-guest path claimed by both a mount and a secret, which the merge
# below would otherwise resolve silently in the secret's favour.
mountCollisions = lib.attrNames (builtins.intersectAttrs userMounts secretMounts);
# A networked guest owns its bridged interface through its own networkd, the only stable MAC pin for a nested container. # A networked guest owns its bridged interface through its own networkd, the only stable MAC pin for a nested container.
# The interface is eth0, the name a nested container gives its bridged veth. # The interface is eth0, the name a nested container gives its bridged veth.
# It takes the placement MAC, and the static address or DHCP when that is unset. # It takes the placement MAC, and the static address or DHCP when that is unset.
@@ -197,10 +224,36 @@ let
read-write unless `readOnly` is set. read-write unless `readOnly` is set.
''; '';
}; };
secrets = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "jellyfin-api-key" ];
description = ''
Names of the secrets this guest needs. The host is the sole
decryptor: it decrypts each named secret from its own sops files and
bind-mounts the plaintext file into the guest read-only at
`/run/secrets/<name>`, the same path it would occupy on a host, so a
service reads its credentials at a predictable location. The guest
names the files it wants and receives exactly those. It holds no age
key and decrypts nothing itself. Ownership carries across unchanged,
since the container maps ids one to one, so a secret owned by a uid on
the host is owned by that same uid inside the guest.
'';
};
}; };
config = lib.mkIf cfg.enable { config = lib.mkIf cfg.enable {
# Declared here so the host is the one that decrypts each named secret.
# The guest carries no age key and decrypts nothing of its own.
sops.secrets = lib.genAttrs cfg.secrets (_: { });
assertions = [ assertions = [
{
assertion = mountCollisions == [ ];
message = ''
guests.${name} maps a mount at ${lib.concatStringsSep ", " mountCollisions}, colliding with a secret bind-mounted at the same path. Rename the mount or the secret so each in-guest path is used once.
'';
}
{ {
assertion = cfg.backend == "container"; assertion = cfg.backend == "container";
message = '' message = ''
@@ -231,10 +284,7 @@ let
# A private-user mapping would shift the ids and reintroduce those errors, so it stays off. # A private-user mapping would shift the ids and reintroduce those errors, so it stays off.
privateUsers = lib.mkDefault "no"; privateUsers = lib.mkDefault "no";
bindMounts = lib.mapAttrs (_guestPath: m: { bindMounts = userMounts // secretMounts;
inherit (m) hostPath;
isReadOnly = m.readOnly;
}) cfg.mounts;
inherit specialArgs; inherit specialArgs;