allod secret rotate: replace a credential's value across its rotation group #207

Closed
allod-agent wants to merge 3 commits from agent/secret-rotate into master
Member

allod secret rotate <name> replaces a credential's value on the host, the third landing shape beside create (first value) and rekey (new recipients). It rotates the whole registry group the credential belongs to from one value on stdin, carries the non-secret fields of the two structured formats forward, runs the repository's checks, commits, pushes, and prints the deploy, verify, and revocation steps from the registry. Nothing changes on any machine until nexus's allod-tools pin moves; the command compiles only behind the secret tag, so dev machines still do not carry it. rotate-token is untouched in this PR and keeps its refresh-local-auth job. The proof is a tagged test suite over fake age, nix, git, and rclone covering every format, refusal, dry run, and landing outcome; no live rotation has run, because no agent machine holds an age identity. If it misbehaves, revert this PR; a landed rotation is a commit on a branch that was never merged.

Two decisions the issue left open

  • Registry rename: not here. forgejo-token-groups.json and lib.forgejoTokenGroups keep their names. The rename is cross-repo (archetypes reads the export) and is now its own issue, allod/secrets#22.
  • Local auth refresh stays in rotate-token. It installs root-owned netrc bundles under sudo, a privilege class a git-repository command should not hold. When the group carries local_auth_refresh entries the printed steps include rotate-token refresh-local-auth --group <alias> as the operator's next step. Retiring the rest of rotate-token once this is deployed is allod/nexus#52.

What the command does

  • allod secret rotate <name> [<checkout>] [--dry-run], tagged secret. The name selects the registry group listing it; every credential in that group is re-encrypted from the one prompted value, the way rotate-token --group does. No selector flags.
  • Gate, same as create: checkout from the repository registry or the positional; non-default branch; clean tree; every credential active with exactly one agenix consumer, its ciphertext present, its secrets.nix recipients including the host identity; registry shape as rotate-token's validator accepts; every group member in exactly one group; formats able to share one prompted value. The branch and tree are rechecked after the value is read, before anything is written.
  • Formats: raw-forgejo-token verbatim; credential-store-url keeps user and host and splices the new token; rclone-remote-stanza keeps type, host, user, and explicit_tls and replaces pass with rclone obscure output. Structured values are refused if they carry whitespace, control characters, or URL delimiters. rclone is looked up before stdin is read.
  • Landing: every ciphertext is produced in memory, written by temp-file-and-rename so a file is always old or new bytes, then nix flake check, restore on failure, one commit (rotate <alias> Forgejo token for forgejo groups, rotate <alias> otherwise), push. A push failure keeps the commit, still prints every step, then exits non-zero.
  • --dry-run runs every gate above, decrypts nothing, reads no value, runs no checks, and prints the group summary and steps.
  • Printed steps: per-target system, kind, deployed path, and verify probe; rebuild commands; verification; the revocation gate with Forgejo wording only for forgejo groups; the merge step in place of manual git.

Risk

R3. Writes to the authoritative secrets repository and pushes, on a non-default branch with a clean tree, every effect built in memory first, checks gating the commit. Worst credible failure after the tests pass is a format carry-forward that differs from rotate-token's on a real ciphertext; the fake-age tests pin the exact plaintext fed to encryption for each format, and the first live rotation should be run with --dry-run first and verified with the printed probe before the old value is revoked.

Validation

  • gofmt -l . empty; go vet and go test ./... green untagged and with site, secret, and site,secret, run by the overseer in the branch worktree after the final commit. rotate is asserted unknown in the untagged build.
  • Tagged tests: group resolution and every refusal (no group, two groups, pending entry, missing ciphertext, dirty tree, default branch, branch changed while waiting, rclone absent, bad structured value, incompatible format mix); each format's carry-forward with the exact plaintext asserted; a mixed-format group from one value; dry run writes nothing; printed steps for forgejo and none groups with and without the refresh step; checks fail restores; push failure prints steps and keeps the commit; atomic replacement leaves no temporary files.
  • Cross-family review, two passes. Two reviewers on the first commit found nine defects (dry run decrypting, dry-run wording, unvalidated structured values, rclone checked after stdin, group membership not unique, printed steps re-instructing git, steps lost on a push failure, branch not rechecked, non-atomic writes), fixed in the second commit. A second pass on that result found five smaller ones (branch not rechecked after the flake check, restore not atomic, % accepted in a credential-store value, the refresh step phrased as an instruction in dry run, a wrong pointer in the push-failure text), fixed in the third commit and read by the overseer.

Not run: a live rotation against real age and nix on the host.

Refs allod/tools#182
Closes allod/tools#197

`allod secret rotate <name>` replaces a credential's value on the host, the third landing shape beside `create` (first value) and `rekey` (new recipients). It rotates the whole registry group the credential belongs to from one value on stdin, carries the non-secret fields of the two structured formats forward, runs the repository's checks, commits, pushes, and prints the deploy, verify, and revocation steps from the registry. Nothing changes on any machine until nexus's `allod-tools` pin moves; the command compiles only behind the `secret` tag, so dev machines still do not carry it. `rotate-token` is untouched in this PR and keeps its `refresh-local-auth` job. The proof is a tagged test suite over fake `age`, `nix`, `git`, and `rclone` covering every format, refusal, dry run, and landing outcome; no live rotation has run, because no agent machine holds an age identity. If it misbehaves, revert this PR; a landed rotation is a commit on a branch that was never merged. ## Two decisions the issue left open - **Registry rename: not here.** `forgejo-token-groups.json` and `lib.forgejoTokenGroups` keep their names. The rename is cross-repo (archetypes reads the export) and is now its own issue, allod/secrets#22. - **Local auth refresh stays in `rotate-token`.** It installs root-owned netrc bundles under sudo, a privilege class a git-repository command should not hold. When the group carries `local_auth_refresh` entries the printed steps include `rotate-token refresh-local-auth --group <alias>` as the operator's next step. Retiring the rest of `rotate-token` once this is deployed is allod/nexus#52. ## What the command does - `allod secret rotate <name> [<checkout>] [--dry-run]`, tagged `secret`. The name selects the registry group listing it; every credential in that group is re-encrypted from the one prompted value, the way `rotate-token --group` does. No selector flags. - Gate, same as `create`: checkout from the repository registry or the positional; non-default branch; clean tree; every credential `active` with exactly one agenix consumer, its ciphertext present, its `secrets.nix` recipients including the host identity; registry shape as `rotate-token`'s validator accepts; every group member in exactly one group; formats able to share one prompted value. The branch and tree are rechecked after the value is read, before anything is written. - Formats: `raw-forgejo-token` verbatim; `credential-store-url` keeps user and host and splices the new token; `rclone-remote-stanza` keeps type, host, user, and explicit_tls and replaces `pass` with `rclone obscure` output. Structured values are refused if they carry whitespace, control characters, or URL delimiters. `rclone` is looked up before stdin is read. - Landing: every ciphertext is produced in memory, written by temp-file-and-rename so a file is always old or new bytes, then `nix flake check`, restore on failure, one commit (`rotate <alias> Forgejo token` for `forgejo` groups, `rotate <alias>` otherwise), push. A push failure keeps the commit, still prints every step, then exits non-zero. - `--dry-run` runs every gate above, decrypts nothing, reads no value, runs no checks, and prints the group summary and steps. - Printed steps: per-target system, kind, deployed path, and verify probe; rebuild commands; verification; the revocation gate with Forgejo wording only for `forgejo` groups; the merge step in place of manual git. ## Risk R3. Writes to the authoritative secrets repository and pushes, on a non-default branch with a clean tree, every effect built in memory first, checks gating the commit. Worst credible failure after the tests pass is a format carry-forward that differs from `rotate-token`'s on a real ciphertext; the fake-`age` tests pin the exact plaintext fed to encryption for each format, and the first live rotation should be run with `--dry-run` first and verified with the printed probe before the old value is revoked. ## Validation - `gofmt -l .` empty; `go vet` and `go test ./...` green untagged and with `site`, `secret`, and `site,secret`, run by the overseer in the branch worktree after the final commit. `rotate` is asserted unknown in the untagged build. - Tagged tests: group resolution and every refusal (no group, two groups, pending entry, missing ciphertext, dirty tree, default branch, branch changed while waiting, rclone absent, bad structured value, incompatible format mix); each format's carry-forward with the exact plaintext asserted; a mixed-format group from one value; dry run writes nothing; printed steps for `forgejo` and `none` groups with and without the refresh step; checks fail restores; push failure prints steps and keeps the commit; atomic replacement leaves no temporary files. - Cross-family review, two passes. Two reviewers on the first commit found nine defects (dry run decrypting, dry-run wording, unvalidated structured values, rclone checked after stdin, group membership not unique, printed steps re-instructing git, steps lost on a push failure, branch not rechecked, non-atomic writes), fixed in the second commit. A second pass on that result found five smaller ones (branch not rechecked after the flake check, restore not atomic, `%` accepted in a credential-store value, the refresh step phrased as an instruction in dry run, a wrong pointer in the push-failure text), fixed in the third commit and read by the overseer. Not run: a live rotation against real `age` and `nix` on the host. Refs allod/tools#182 Closes allod/tools#197
Ports the group-rotation half of nexus's rotate-token into the secret
namespace, behind the secret build tag, so landing a credential's first
value, a new value, and a new recipient list is one command family.
'allod secret rotate <name> [<checkout>] [--dry-run]' selects the whole
rotation registry group a credential belongs to, re-encrypts every member
from one value read on stdin, and lands it with create's gate and its
checks-then-commit-and-push landing.

The three plaintext formats carry forward the way rotate-token's do:
raw-forgejo-token writes the value as is, credential-store-url and
rclone-remote-stanza decrypt the old ciphertext first and keep its
non-secret fields, and a group mixing rclone-remote-stanza with a token
format is refused. --dry-run runs every gate check and prints the group
summary, deploy and verification steps, and revocation gate without
reading a value or touching a file. Registry rename and refresh-local-auth
stay out of scope per the overseer's decisions: forgejo-token-groups.json
and lib.forgejoTokenGroups keep their names, and a group with a
local_auth_refresh entry only gets a printed 'rotate-token
refresh-local-auth --group <alias>' deploy step, since installing that
bundle needs root privileges this command does not hold.

Refs allod/tools#197
Dry run no longer decrypts a group's old ciphertexts (the printed steps
never used the decrypted user or host), matching the "no prompt, decrypt,
or encrypt" banner and the docs; usage text and docs now say a dry run
runs the command's own gate, not the repository's flake check. A
structured value is validated after trimming: whitespace, a control
character, or (for credential-store-url) a URL-reserved character is
refused rather than silently spliced into a malformed URL that would
still get encrypted, checked, committed, and pushed. rclone's presence on
PATH is checked before the value is ever read from stdin, not after.
Group selection now verifies every member of the selected group belongs
to exactly one registry group, not only the credential named on the
command line. The printed deploy steps no longer tell the operator to
git add/commit/push a landing this command already made; they name the
merge step instead, and in a dry run describe what a live run would
commit and push. A push failure still prints the group's deploy, verify,
and revocation steps before the command exits non-zero, so rotating the
very credential that authenticates the push does not strand the operator
without instructions. The branch is re-checked immediately before writing
the ciphertexts, refusing if the checkout moved during the wait for the
value. Every ciphertext is now written to a temporary file and renamed
into place, so a crash mid-write cannot leave a truncated file.

landCommit's push-failure handling moves into a new
landCommitOrReportPush that returns the failure instead of dying, so
rotate can print its steps first; create and rekey keep landCommit and
see no behavior change.

Refs allod/tools#197
The branch is now re-verified a second time, inside the landing helper
itself immediately before 'git add', closing the window a slow nix flake
check leaves open after the earlier pre-write recheck; the check moved
into landCommitOrReportPush (via a new branch parameter landCommit also
now takes) so create and rekey get the same protection for free, with no
change to their own behavior. Restoring a ciphertext after a failed check
or a branch change now goes through the same temp-file-and-rename helper
the new ciphertexts use, instead of a plain file write. credential-store-url
now also refuses a value containing '%': git percent-decodes a
credential-store URL's password, so an unrefused '%' would let the stored
value differ from the one that was typed. A dry run's refresh-local-auth
line now reads as what the real run's steps will say, not as an
instruction to run before any ciphertext has actually been rotated. The
push-failure step now says its error follows below, matching where it
actually prints, instead of claiming it is above.

Refs allod/tools#197
Author
Member

Superseded by allod/tools PR 208, which carries this branch's reviewed rotate work (squashed onto current master, adapted to the merged declare dispatcher) and moves declare, create, rotate, and the new migrate onto the credential value-template contract of allod/secrets#23. Nothing here was lost: every landing gate and its tests are in the new PR. The remote branch stays until that PR merges.

Superseded by allod/tools PR 208, which carries this branch's reviewed rotate work (squashed onto current master, adapted to the merged declare dispatcher) and moves declare, create, rotate, and the new migrate onto the credential value-template contract of allod/secrets#23. Nothing here was lost: every landing gate and its tests are in the new PR. The remote branch stays until that PR merges.
allod-agent closed this pull request 2026-09-12 02:00:11 +01:00

Pull request closed

Sign in to join this conversation.
No description provided.