Retire rotate-token; refresh-local-auth becomes its own host script that accepts both registry shapes #55

Merged
vnprc merged 3 commits from agent/retire-rotate-token into master 2026-09-12 23:55:42 +01:00
Member

On a host that has picked up this nexus pin, rotate-token is gone and value rotation is allod secret rotate <name> alone. Refreshing the host's netrc bundle after such a rotation becomes a script of its own name, refresh-local-auth --group <alias>, which does exactly what rotate-token refresh-local-auth did (root and user netrc files installed under sudo, backed up and rolled back as a set) and now also accepts a source credential already in the registry's new shape, which the old validator refused. Nothing else on the host changes: the other provisioning scripts, the secrets, and the deployed files are untouched until an operator runs the new script. Proof is the script's own test suite (18 tests, including a new-shape source installing byte-identical to a legacy one and every refusal), shellcheck, the flake's scripts check, and two cold reviews by models that did not write it. Do not merge before the replacement is proven live: run allod secret rotate --dry-run and then a real rotation on a group without local_auth_refresh entries, with the printed verification, first. If this is wrong after merging, revert the commit and move the nexus pin back; nothing this PR ships writes anything until invoked.

Decision: where refresh-local-auth lives

Its own host script in scripts/refresh-local-auth, not a subcommand of a rotate-token that no longer rotates. A script named for what it does is what a reader of allod secret rotate's printed steps expects to find, and the cost is one string on the tools side, changed in the companion PR (allod/tools PR 210). No compatibility shim is kept: the only window in which the old spelling could be printed for a host that lacks it is the one the pin order below avoids, and the failure there is a loud "command not found", not a silent wrong install.

What changes

  • scripts/refresh-local-auth (new): --group <registry-alias> and --help. Registry read, unknown-group refusal, wrong-host refusal, sudo preflight before any decrypt, age decrypt into a variable, the one-line https://<user>:<token>@<host> parse, the four-file bundle (/root/.git-credentials 0600, /etc/nix/netrc 0640 root:users, /root/.netrc 0600, /home/<user>/.netrc 0600) with backup and full rollback, and the idempotent re-run are carried over unchanged. Two things are tightened after review: the plaintext workdir is the lib's make_tmpfs_workdir under /dev/shm (overridable with REFRESH_LOCAL_AUTH_TMPDIR, refusing a non-tmpfs parent unless REFRESH_LOCAL_AUTH_ALLOW_NON_TMPFS=true), as every other rotation script here does; and every refusal that needs no plaintext (wrong host, privilege, each destination's parent directory, the local user's existence) is settled before age runs. A symlink at any destination, dangling or not, is refused before anything is decrypted or backed up: the archetypes netrc activation script and agenix both install these files as regular files, and following a link would have restored the old credential as a regular file at the link's own mode on rollback. Test hooks are renamed from ROTATE_TOKEN_* to REFRESH_LOCAL_AUTH_*.
  • The registry check validates only what a refresh consumes: each local_auth_refresh entry's contract, system, user name, and source credential, and that the source is a credential-store URL source with one /root/.git-credentials target on that system. A source qualifies in either shape: legacy format: credential-store-url with no value, or new-shape value.template with no encode, exactly one {secret}, and exactly one non-blank line (blank meaning spaces and tabs only) matching the same URL grammar. The predicate mirrors isCredentialStoreUrlSource in the archetypes flake and isCredentialStoreURLSource in allod secret character for character. The old validator also required legacy format and verify.type enums on every credential, which is why it refused the current public registry.
  • scripts/rotate-token and tests/rotate-token.sh are deleted. tests/refresh-local-auth.sh replaces the latter with the refresh-relevant tests plus the new-shape cases.
  • flake.nix names the new script and test in the scripts check; nix/provisioning-scripts.nix drops the rclone wrapper that existed only for stanza rotation, and with it makeWrapper.
  • docs/credentials.md describes the current registry shape (value.template, optional value.encode, one verify command per target) and points rotation at allod secret rotate; docs/integration.md says the rclone password rotates through allod secret rotate with value.encode rclone-obscure.

Ordering and agent gates

The allod-tools pin lives in the deployment's flake, not here. Three human steps, in order:

  1. Advance the tools pin to a revision that carries allod secret rotate (allod/tools master at or after allod/tools PR 208) and prove it live on a group without local_auth_refresh entries: allod secret rotate --dry-run <name>, the real run, the printed verification. Only then merge this PR.
  2. Advance the nexus pin to this PR no later than the tools pin that carries allod/tools PR 210, ideally in the same lock bump. That tools change prints refresh-local-auth --group <alias> and lets allod secret migrate handle a local_auth_refresh source only when refresh-local-auth is on PATH; a host with the new tools and the old nexus would print a step that does not exist, and a host with the old tools and the new nexus can still rotate every group but cannot migrate the local-auth one yet.
  3. Migrate the local-auth group with allod secret migrate <name>, rotate it with allod secret rotate, and run the printed refresh-local-auth --group <alias>.

Risk

R3: a host script that writes root-owned authentication files under sudo, plus cross-repo sequencing with the tools change. Residual after the tests: the predicate and the archetypes check could drift apart in a future edit (each is commented with the other's name, and the archetypes check runs on every secrets change); and a plaintext that parses but carries a trailing newline is installed as-is, exactly as before. Rollback is a revert plus moving the nexus pin back; the script writes nothing until run.

Validation

Run by the overseer in the worktree on the committed tree (be918d4, three commits):

command last line exit
bash -n scripts/refresh-local-auth tests/refresh-local-auth.sh no output 0
bash tests/refresh-local-auth.sh 18 refresh-local-auth tests passed. 0
bash tests/rotation-common.sh 32 rotation-common tests passed. 0
nix build --no-link -L .#checks.x86_64-linux.provisioning-contract (runs shellcheck and every test) built 0
nix build .#packages.x86_64-linux.provisioning-scripts then ls bin refresh-local-auth present, rotate-token absent 0
git diff --check no output 0
grep -rn 'rotate-token|ROTATE_TOKEN' --exclude-dir=.git . one comment in the new script explaining why its check is narrow 0

The worker additionally sabotaged the script eleven ways (predicate accepting everything or encode: false, preflight skipped, parent-directory check removed, rollback removed, netrc mode widened, empty-list check removed, tmpfs workdir replaced by bare mktemp, symlink refusal removed, local-user check moved after the decrypt) and reported a different test failing each time, with the script restored byte-identical afterwards.

Cold reviews, each reading a .git-free snapshot and the diff against master, both from the GPT family because the change was authored by a Claude model. First pass on the first commit: gpt-5.6-terra at thinking high through pi and gpt-5.5 at effort high through codex. Between them they found, all verified against the source and fixed in the second commit with a test each: value.encode: false accepted by jq's // while the archetypes predicate rejects it; the plaintext workdir was a bare mktemp -d instead of the lib's tmpfs helper every other rotation script uses; age ran before the destination directories and the local user were checked; and a dangling symlink destination was recorded absent and unlinked on rollback. One terra finding was discarded: jq and Nix differ on Unicode whitespace inside the URL grammar, which is a fail-closed refusal on an input nobody writes. Second pass on the fixed tree by gpt-5.5 through codex found one high (a non-dangling symlink destination would be restored as a regular file at the link's 777 mode on rollback) and one low (no test proved the user check ran before decrypt); both fixed in the third commit, the first by refusing any symlink destination outright since the framework installs these files as regular files. The third commit is a small fix I verified by reading the diff and running the suite and its sabotage lines; no third cold pass, per the review budget for this risk level.

Not run: a live refresh on a real host, which needs the age identity and sudo. The first live run should be on the host after step 1 above, with the four destination files compared before and after.

Closes allod/nexus#52
Refs allod/tools#197

On a host that has picked up this nexus pin, `rotate-token` is gone and value rotation is `allod secret rotate <name>` alone. Refreshing the host's netrc bundle after such a rotation becomes a script of its own name, `refresh-local-auth --group <alias>`, which does exactly what `rotate-token refresh-local-auth` did (root and user netrc files installed under sudo, backed up and rolled back as a set) and now also accepts a source credential already in the registry's new shape, which the old validator refused. Nothing else on the host changes: the other provisioning scripts, the secrets, and the deployed files are untouched until an operator runs the new script. Proof is the script's own test suite (18 tests, including a new-shape source installing byte-identical to a legacy one and every refusal), shellcheck, the flake's scripts check, and two cold reviews by models that did not write it. Do not merge before the replacement is proven live: run `allod secret rotate --dry-run` and then a real rotation on a group without `local_auth_refresh` entries, with the printed verification, first. If this is wrong after merging, revert the commit and move the nexus pin back; nothing this PR ships writes anything until invoked. ## Decision: where refresh-local-auth lives Its own host script in `scripts/refresh-local-auth`, not a subcommand of a `rotate-token` that no longer rotates. A script named for what it does is what a reader of `allod secret rotate`'s printed steps expects to find, and the cost is one string on the tools side, changed in the companion PR (allod/tools PR 210). No compatibility shim is kept: the only window in which the old spelling could be printed for a host that lacks it is the one the pin order below avoids, and the failure there is a loud "command not found", not a silent wrong install. ## What changes - `scripts/refresh-local-auth` (new): `--group <registry-alias>` and `--help`. Registry read, unknown-group refusal, wrong-host refusal, sudo preflight before any decrypt, age decrypt into a variable, the one-line `https://<user>:<token>@<host>` parse, the four-file bundle (`/root/.git-credentials` 0600, `/etc/nix/netrc` 0640 root:users, `/root/.netrc` 0600, `/home/<user>/.netrc` 0600) with backup and full rollback, and the idempotent re-run are carried over unchanged. Two things are tightened after review: the plaintext workdir is the lib's `make_tmpfs_workdir` under `/dev/shm` (overridable with `REFRESH_LOCAL_AUTH_TMPDIR`, refusing a non-tmpfs parent unless `REFRESH_LOCAL_AUTH_ALLOW_NON_TMPFS=true`), as every other rotation script here does; and every refusal that needs no plaintext (wrong host, privilege, each destination's parent directory, the local user's existence) is settled before age runs. A symlink at any destination, dangling or not, is refused before anything is decrypted or backed up: the archetypes netrc activation script and agenix both install these files as regular files, and following a link would have restored the old credential as a regular file at the link's own mode on rollback. Test hooks are renamed from `ROTATE_TOKEN_*` to `REFRESH_LOCAL_AUTH_*`. - The registry check validates only what a refresh consumes: each `local_auth_refresh` entry's contract, system, user name, and source credential, and that the source is a credential-store URL source with one `/root/.git-credentials` target on that system. A source qualifies in either shape: legacy `format: credential-store-url` with no `value`, or new-shape `value.template` with no `encode`, exactly one `{secret}`, and exactly one non-blank line (blank meaning spaces and tabs only) matching the same URL grammar. The predicate mirrors `isCredentialStoreUrlSource` in the archetypes flake and `isCredentialStoreURLSource` in `allod secret` character for character. The old validator also required legacy `format` and `verify.type` enums on every credential, which is why it refused the current public registry. - `scripts/rotate-token` and `tests/rotate-token.sh` are deleted. `tests/refresh-local-auth.sh` replaces the latter with the refresh-relevant tests plus the new-shape cases. - `flake.nix` names the new script and test in the scripts check; `nix/provisioning-scripts.nix` drops the rclone wrapper that existed only for stanza rotation, and with it `makeWrapper`. - `docs/credentials.md` describes the current registry shape (`value.template`, optional `value.encode`, one `verify` command per target) and points rotation at `allod secret rotate`; `docs/integration.md` says the rclone password rotates through `allod secret rotate` with `value.encode` `rclone-obscure`. ## Ordering and agent gates The `allod-tools` pin lives in the deployment's flake, not here. Three human steps, in order: 1. Advance the tools pin to a revision that carries `allod secret rotate` (allod/tools master at or after allod/tools PR 208) and prove it live on a group without `local_auth_refresh` entries: `allod secret rotate --dry-run <name>`, the real run, the printed verification. Only then merge this PR. 2. Advance the nexus pin to this PR no later than the tools pin that carries allod/tools PR 210, ideally in the same lock bump. That tools change prints `refresh-local-auth --group <alias>` and lets `allod secret migrate` handle a `local_auth_refresh` source only when `refresh-local-auth` is on PATH; a host with the new tools and the old nexus would print a step that does not exist, and a host with the old tools and the new nexus can still rotate every group but cannot migrate the local-auth one yet. 3. Migrate the local-auth group with `allod secret migrate <name>`, rotate it with `allod secret rotate`, and run the printed `refresh-local-auth --group <alias>`. ## Risk R3: a host script that writes root-owned authentication files under sudo, plus cross-repo sequencing with the tools change. Residual after the tests: the predicate and the archetypes check could drift apart in a future edit (each is commented with the other's name, and the archetypes check runs on every secrets change); and a plaintext that parses but carries a trailing newline is installed as-is, exactly as before. Rollback is a revert plus moving the nexus pin back; the script writes nothing until run. ## Validation Run by the overseer in the worktree on the committed tree (`be918d4`, three commits): | command | last line | exit | |---|---|---| | `bash -n scripts/refresh-local-auth tests/refresh-local-auth.sh` | no output | 0 | | `bash tests/refresh-local-auth.sh` | `18 refresh-local-auth tests passed.` | 0 | | `bash tests/rotation-common.sh` | `32 rotation-common tests passed.` | 0 | | `nix build --no-link -L .#checks.x86_64-linux.provisioning-contract` (runs shellcheck and every test) | built | 0 | | `nix build .#packages.x86_64-linux.provisioning-scripts` then `ls bin` | `refresh-local-auth` present, `rotate-token` absent | 0 | | `git diff --check` | no output | 0 | | `grep -rn 'rotate-token\|ROTATE_TOKEN' --exclude-dir=.git .` | one comment in the new script explaining why its check is narrow | 0 | The worker additionally sabotaged the script eleven ways (predicate accepting everything or `encode: false`, preflight skipped, parent-directory check removed, rollback removed, netrc mode widened, empty-list check removed, tmpfs workdir replaced by bare `mktemp`, symlink refusal removed, local-user check moved after the decrypt) and reported a different test failing each time, with the script restored byte-identical afterwards. Cold reviews, each reading a `.git`-free snapshot and the diff against master, both from the GPT family because the change was authored by a Claude model. First pass on the first commit: `gpt-5.6-terra` at thinking high through pi and `gpt-5.5` at effort high through codex. Between them they found, all verified against the source and fixed in the second commit with a test each: `value.encode: false` accepted by jq's `//` while the archetypes predicate rejects it; the plaintext workdir was a bare `mktemp -d` instead of the lib's tmpfs helper every other rotation script uses; age ran before the destination directories and the local user were checked; and a dangling symlink destination was recorded absent and unlinked on rollback. One terra finding was discarded: jq and Nix differ on Unicode whitespace inside the URL grammar, which is a fail-closed refusal on an input nobody writes. Second pass on the fixed tree by `gpt-5.5` through codex found one high (a non-dangling symlink destination would be restored as a regular file at the link's 777 mode on rollback) and one low (no test proved the user check ran before decrypt); both fixed in the third commit, the first by refusing any symlink destination outright since the framework installs these files as regular files. The third commit is a small fix I verified by reading the diff and running the suite and its sabotage lines; no third cold pass, per the review budget for this risk level. Not run: a live refresh on a real host, which needs the age identity and sudo. The first live run should be on the host after step 1 above, with the four destination files compared before and after. Closes allod/nexus#52 Refs allod/tools#197
Value rotation now has one implementation. 'allod secret rotate <name>' on the
host does everything rotate-token's rotate, --group, --forgejo-token, and
single-secret paths did, so those paths and their tests are gone. What it
deliberately does not absorb is the local auth refresh, because that installs
root-owned files under sudo and a command that edits a git repository should
not hold that privilege. It stays here as scripts/refresh-local-auth --group
<registry-alias>, a script of its own name with no compatibility shim: the
operator runs the printed next step under its new spelling, and the printed
step in allod/tools changes to match.

For a running host the installed bundle is unchanged. The same four files are
written from the same decrypted credential with the same owners and modes, the
same backup and full rollback on any failure, the same refusals for the wrong
host, for missing privilege before anything is decrypted, and for a plaintext
that is not one credential-store URL line. Only the command name and its
environment hooks changed, and the rotate-token binary is no longer installed.

The substance of the change is what the registry may now say. The old
validator required a credential 'format' and a structured target 'verify'
type, and the public registry has moved on from both, so it refused entries
that are perfectly valid today. The new check reads only what a refresh
consumes and accepts a source credential in either shape: the legacy
credential-store-url format, or a value.template rendering exactly one
https://user:{secret}@host line. That predicate mirrors the archetypes
netrc check and 'allod secret rotate' character for character, so all three
agree on which credential a refresh may act on.

Refs allod/nexus#52
The registry predicate used jq's '//' to test for an absent value.encode.
That operator treats false as absent, so a credential declaring
"encode": false would have been accepted here while the archetypes netrc
check refuses it, and the two must agree exactly on which source a refresh
may act on. The test is now '== null', which passes an absent or null encode
and refuses false along with any string.

The decrypted credential and the netrc line were written to a bare
'mktemp -d', which lands on disk on most hosts. They now go to the same
tmpfs workdir every other rotation script in this repo uses, through the
lib's make_tmpfs_workdir, with REFRESH_LOCAL_AUTH_TMPDIR and
REFRESH_LOCAL_AUTH_ALLOW_NON_TMPFS following the convention the other
scripts already document. A non-tmpfs parent is refused rather than used.

Two refusals came too late. A destination directory that does not exist, and
a local user the install would fail to chown to, were only discovered after
the ciphertext had been opened; both are now settled in preflight, so a host
that cannot take the bundle never decrypts it. And the presence probe was a
bare 'test -e', which is false for a dangling symlink: such a destination was
recorded as absent, and a rollback would have unlinked it instead of putting
it back. Any link now counts as present, the backup of a dangling one fails,
and the run refuses before installing a single file.

Refs allod/nexus#52
A symlink at any of the four destinations is now refused outright, before
anything is decrypted or backed up. Treating a link as present was not enough:
one that resolves passes a plain existence test, the backup follows it to the
target's bytes, and the mode recorded is the link's own 0777, so an install
that failed afterwards would restore a world-readable regular file holding the
old credential where a link used to be. None of the four is a link in the
contract this reinstalls - the netrc files come from an install in the
archetypes netrc activation script and /root/.git-credentials is agenix
deployed the same way - so preflight refuses any link naming the path, and the
backup loop goes back to a plain existence probe.

The local user check moved ahead of the privilege gate. It needs no privilege
of its own, and running it first means a refresh that names a user this host
does not have says so instead of first prompting for sudo. That order is now
what makes it testable: a run with no simulated root and a local_username that
cannot exist refuses before sudo and before the decrypt, which a fixture age
wrapper proves by never being invoked.

The check itself is 'id -u' rather than 'getent passwd'. Both resolve through
NSS, as install -o does, but getent is not always on PATH - it is absent from
the nix build sandbox - and its absence would make this refuse every refresh
with a message blaming the user name.

Refs allod/nexus#52
vnprc approved these changes 2026-09-12 23:55:36 +01:00
vnprc merged commit be918d47fb into master 2026-09-12 23:55:42 +01:00
vnprc deleted branch agent/retire-rotate-token 2026-09-12 23:55:42 +01:00
Sign in to join this conversation.
No description provided.