Carve out consumed files so a dev machine stops carrying the whole secrets repo #21

Open
opened 2026-07-31 02:00:01 +01:00 by vnprc-agent · 0 comments

Reduce what a dev machine carries from its deployment's secrets repo to the files that machine actually uses, instead of the whole repo. Two modules interpolate the secrets flake and append a path, and because a flake input resolves to a directory, that pulls in every encrypted file the repo holds — including ciphertexts belonging to other machines, which the machine cannot decrypt and has no reason to store.

Primary goals:

  • Confine the reference — each consumed file is carved out as its own store path, so a machine's closure holds the files it uses and nothing else.
  • Cut the blast radius of one guest compromise — an attacker on a dev guest currently walks away with a copy of every ciphertext in the deployment, to keep and attack offline; a host key that leaks later then decrypts whatever was kept.
  • Stop unrelated edits rebuilding every machine — any commit to the secrets repo rehashes the directory today, so editing one machine's data moves every machine that references it.
  • Keep the change inside this repo — no new export from the identity template and no change to any deployment's data repos.

Current state and specifics

modules/agent-hooks.nix:14,16,18,20 set four home.file sources by interpolating gitPolicySource and appending /git/<name>. modules/github-credentials.nix:29 does the same with "${secrets}/${consumer.secret}". In both cases the resulting string names a file inside the input's store directory, so the derivation depends on the directory.

Measured on a deployment: a dev machine's home configuration resolves its git policy file to /nix/store/<hash>-source/git/protected-branches, and that <hash>-source directory contains every .age file in the repo — other machines' SSH host keys, other machines' Forge keys, and credentials that have nothing to do with git policy or with that machine.

This is exposure, not access. Which machine can decrypt which ciphertext is governed by the recipient graph in secrets.nix, and that is unaffected and correct: the machines cannot open what they are carrying, apart from credentials that are deliberately shared. The defect is that they carry it at all.

Two references that look similar are not in scope and should stay as they are. flake.nix:717 and flake.nix:723 interpolate the source inside a check derivation, which is built on demand and never lands on a machine. A check that inspects the repo is supposed to see the repo.

Suggested default, verified to work:

builtins.path { path = "${gitPolicySource}/git/${name}"; name = name; }

This yields a content-addressed store path holding the single file, with an empty reference set — no path back to the source directory. It behaves the same whether the argument is a literal path or a string carrying the input's context, so no context-discarding helper is needed. The identity template already uses this construction for one credential, so the pattern is established rather than new.

An alternative worth weighing before implementing: have the identity template export the carved paths and have these modules consume them. That moves the choice to the repo that owns the files, but it costs a new export in the template plus a matching change in every fork's data repo, for the same result. The default above is preferred because it is confined to the consuming modules.

Scope

In scope: the two modules above, and whatever check pins the property so the reference cannot creep back.

Out of scope: the recipient graph, which is correct; the identity template's exports; any deployment's data repos; and the check-derivation references in flake.nix.

A deployment's hypervisor identity may interpolate its own source the same way. That is a separate change in a different identity function, and it is also far less interesting, since the hypervisor is already a recipient of nearly everything it would be carrying.

Validation

  • A dev machine's toplevel closure no longer contains the secrets source directory. Query the closure for that store path before and after; it should disappear entirely, not merely shrink.
  • The four policy files and the GitHub credential still land with identical content, destination path, owner, and mode.
  • credential-profiles still binds every declared token target to a generated secret. It already accepts a carved single-file path — it matches either a full /<source-relative-path> suffix or a -<basename> suffix — so this should pass unchanged, and its passing is what proves the carve-out did not break the binding.
  • A check that fails if either module reintroduces a bare interpolation of the input root, so the property is enforced rather than reviewed.
Reduce what a dev machine carries from its deployment's secrets repo to the files that machine actually uses, instead of the whole repo. Two modules interpolate the secrets flake and append a path, and because a flake input resolves to a directory, that pulls in every encrypted file the repo holds — including ciphertexts belonging to other machines, which the machine cannot decrypt and has no reason to store. Primary goals: - **Confine the reference** — each consumed file is carved out as its own store path, so a machine's closure holds the files it uses and nothing else. - **Cut the blast radius of one guest compromise** — an attacker on a dev guest currently walks away with a copy of every ciphertext in the deployment, to keep and attack offline; a host key that leaks later then decrypts whatever was kept. - **Stop unrelated edits rebuilding every machine** — any commit to the secrets repo rehashes the directory today, so editing one machine's data moves every machine that references it. - **Keep the change inside this repo** — no new export from the identity template and no change to any deployment's data repos. ### Current state and specifics `modules/agent-hooks.nix:14,16,18,20` set four `home.file` sources by interpolating `gitPolicySource` and appending `/git/<name>`. `modules/github-credentials.nix:29` does the same with `"${secrets}/${consumer.secret}"`. In both cases the resulting string names a file *inside* the input's store directory, so the derivation depends on the directory. Measured on a deployment: a dev machine's home configuration resolves its git policy file to `/nix/store/<hash>-source/git/protected-branches`, and that `<hash>-source` directory contains every `.age` file in the repo — other machines' SSH host keys, other machines' Forge keys, and credentials that have nothing to do with git policy or with that machine. This is exposure, not access. Which machine can decrypt which ciphertext is governed by the recipient graph in `secrets.nix`, and that is unaffected and correct: the machines cannot open what they are carrying, apart from credentials that are deliberately shared. The defect is that they carry it at all. Two references that look similar are not in scope and should stay as they are. `flake.nix:717` and `flake.nix:723` interpolate the source inside a check derivation, which is built on demand and never lands on a machine. A check that inspects the repo is supposed to see the repo. Suggested default, verified to work: ```nix builtins.path { path = "${gitPolicySource}/git/${name}"; name = name; } ``` This yields a content-addressed store path holding the single file, with an empty reference set — no path back to the source directory. It behaves the same whether the argument is a literal path or a string carrying the input's context, so no context-discarding helper is needed. The identity template already uses this construction for one credential, so the pattern is established rather than new. An alternative worth weighing before implementing: have the identity template export the carved paths and have these modules consume them. That moves the choice to the repo that owns the files, but it costs a new export in the template plus a matching change in every fork's data repo, for the same result. The default above is preferred because it is confined to the consuming modules. ### Scope In scope: the two modules above, and whatever check pins the property so the reference cannot creep back. Out of scope: the recipient graph, which is correct; the identity template's exports; any deployment's data repos; and the check-derivation references in `flake.nix`. A deployment's hypervisor identity may interpolate its own source the same way. That is a separate change in a different identity function, and it is also far less interesting, since the hypervisor is already a recipient of nearly everything it would be carrying. ### Validation - A dev machine's toplevel closure no longer contains the secrets source directory. Query the closure for that store path before and after; it should disappear entirely, not merely shrink. - The four policy files and the GitHub credential still land with identical content, destination path, owner, and mode. - `credential-profiles` still binds every declared token target to a generated secret. It already accepts a carved single-file path — it matches either a full `/<source-relative-path>` suffix or a `-<basename>` suffix — so this should pass unchanged, and its passing is what proves the carve-out did not break the binding. - A check that fails if either module reintroduces a bare interpolation of the input root, so the property is enforced rather than reviewed.
Sign in to join this conversation.
No description provided.