Teach rotate-token the rclone remote stanza format #47

Merged
vnprc merged 3 commits from agent/rotate-token-rclone-stanza into master 2026-09-10 16:47:41 +01:00
Member

rotate-token learns a third secret format, rclone-remote-stanza, so the shared hosting password behind allod site deploy rotates through the same tool as the forge tokens instead of a hand recipe. A rotation decrypts the existing stanza, keeps host and user as they are, asks for the new password on a hidden prompt, obscures it by piping it to rclone obscure - on stdin, and re-encrypts the whole stanza to the existing recipients, byte-for-byte what allod site config writes. The password and its obscured form are never printed or passed on a command line. Nothing changes for the two existing formats, and no group in the public secrets template uses the new one, so no host rotates differently until a deployment's own registry adds an entry. The packaged rotate-token now carries its own rclone rather than relying on the operator's PATH. The suite passes with six new tests, each shown to fail when its property is sabotaged. If this is wrong, the format branch is additive; removing the registry entry returns a deployment to the hand recipe.

Closes allod/nexus#42. Closes allod/strategy#51, whose other children are allod/secrets#18, allod/archetypes#75, allod/nexus#45, and allod/tools#181; merge this one last so the arc closes after the chain lands.

What changed

  • scripts/rotate-token: prepare_item accepts rclone-remote-stanza, decrypts and parses the existing stanza (exactly one [shared] section with type, host, user, pass, explicit_tls in that order, no control characters, anything else dies), and stage_items rebuilds it from the carried-forward fields plus the obscured password, keeping the trailing newline remoteStanza writes. The rclone obscure - output must be a single base64url token. A group that mixes the stanza format with a token format is refused before any prompt, since one prompted value cannot be both. The prompt says "password" for stanza groups. The registry validator admits the format and a site-check verification type, whose printed advice is allod site check; the registry stays Forgejo-shaped, as the issue allows, so a deployment's entry still fills the forge-named fields with placeholders. The plaintext formats are documented in the script header, which is the only place they were described.
  • nix/provisioning-scripts.nix: wraps rotate-token with pkgs.rclone on its PATH.
  • tests/rotate-token.sh: a mock rclone whose obscure - echoes a marker derived from stdin, and six tests: dry-run listing, a rotation that rewrites only pass (compared as bytes through a file), a malformed stanza dying before any prompt, recipients preserved, the password absent from all output, rclone missing being a clear failure, and a mixed group refused.

A pre-existing defect in the test harness surfaced while proving these tests can fail: run_test calls each test inside an if condition, so bash ignores set -e there and only the last command of each test function is enforced. That is filed as allod/nexus#46 and not fixed here. The new tests end every assertion with || return 1 so they hold under the current harness.

Risk

R2. The script handles a live credential as the operator; the new branch touches the plaintext only in memory and never logs it, and the stanza it writes was compared to the Go writer's format by reading both. Not verified: a rotation against a real hosting account and a real rclone; the tests use a mock whose obscure - output has the right shape. The public secrets template gets no registry entry, so nothing exercises this on a public machine.

Validation

  • bash tests/rotate-token.sh: 36 tests pass.
  • nix build .#checks.x86_64-linux.provisioning-contract passes in the sandbox, where rclone is genuinely absent, which is what the missing-rclone test relies on.
  • nix build .#packages.x86_64-linux.provisioning-scripts: the built bin/rotate-token wrapper prepends the rclone store path to PATH.
  • shellcheck -x on the script and the test file: clean.
  • Sabotage runs, each reverted and the suite green again: removing the trailing-newline line, loosening the parser to drop explicit_tls, dropping require_rclone, dropping the second recipient from the recipient list, echoing the password to stderr, and dropping the mixed-format guard each failed exactly the test that pins that property.
  • Reviewed by codex (gpt-5.6-sol) and pi (gpt-oss-120b) on the first and final diffs. Applied: the trailing newline and byte comparison, control-character rejection in the parser, validation of the obscured token's shape, and the mixed-group refusal. Kept as-is after checking: an existing stanza with no final newline or trailing blank lines is accepted and rewritten in canonical form, and the parser does not single out the two Unicode line separators the Go writer refuses, since a stanza that writer produced cannot contain them. A pi claim of a doubled trailing newline is false: command substitution strips it and exactly one is added, which the byte comparison pins.

Labels are unavailable to this token; none is warranted.

`rotate-token` learns a third secret format, `rclone-remote-stanza`, so the shared hosting password behind `allod site deploy` rotates through the same tool as the forge tokens instead of a hand recipe. A rotation decrypts the existing stanza, keeps `host` and `user` as they are, asks for the new password on a hidden prompt, obscures it by piping it to `rclone obscure -` on stdin, and re-encrypts the whole stanza to the existing recipients, byte-for-byte what `allod site config` writes. The password and its obscured form are never printed or passed on a command line. Nothing changes for the two existing formats, and no group in the public secrets template uses the new one, so no host rotates differently until a deployment's own registry adds an entry. The packaged `rotate-token` now carries its own `rclone` rather than relying on the operator's PATH. The suite passes with six new tests, each shown to fail when its property is sabotaged. If this is wrong, the format branch is additive; removing the registry entry returns a deployment to the hand recipe. Closes allod/nexus#42. Closes allod/strategy#51, whose other children are allod/secrets#18, allod/archetypes#75, allod/nexus#45, and allod/tools#181; merge this one last so the arc closes after the chain lands. ## What changed - `scripts/rotate-token`: `prepare_item` accepts `rclone-remote-stanza`, decrypts and parses the existing stanza (exactly one `[shared]` section with `type`, `host`, `user`, `pass`, `explicit_tls` in that order, no control characters, anything else dies), and `stage_items` rebuilds it from the carried-forward fields plus the obscured password, keeping the trailing newline `remoteStanza` writes. The `rclone obscure -` output must be a single base64url token. A group that mixes the stanza format with a token format is refused before any prompt, since one prompted value cannot be both. The prompt says "password" for stanza groups. The registry validator admits the format and a `site-check` verification type, whose printed advice is `allod site check`; the registry stays Forgejo-shaped, as the issue allows, so a deployment's entry still fills the forge-named fields with placeholders. The plaintext formats are documented in the script header, which is the only place they were described. - `nix/provisioning-scripts.nix`: wraps `rotate-token` with `pkgs.rclone` on its PATH. - `tests/rotate-token.sh`: a mock `rclone` whose `obscure -` echoes a marker derived from stdin, and six tests: dry-run listing, a rotation that rewrites only `pass` (compared as bytes through a file), a malformed stanza dying before any prompt, recipients preserved, the password absent from all output, rclone missing being a clear failure, and a mixed group refused. A pre-existing defect in the test harness surfaced while proving these tests can fail: `run_test` calls each test inside an `if` condition, so bash ignores `set -e` there and only the last command of each test function is enforced. That is filed as allod/nexus#46 and not fixed here. The new tests end every assertion with `|| return 1` so they hold under the current harness. ## Risk R2. The script handles a live credential as the operator; the new branch touches the plaintext only in memory and never logs it, and the stanza it writes was compared to the Go writer's format by reading both. Not verified: a rotation against a real hosting account and a real `rclone`; the tests use a mock whose `obscure -` output has the right shape. The public secrets template gets no registry entry, so nothing exercises this on a public machine. ## Validation - `bash tests/rotate-token.sh`: 36 tests pass. - `nix build .#checks.x86_64-linux.provisioning-contract` passes in the sandbox, where rclone is genuinely absent, which is what the missing-rclone test relies on. - `nix build .#packages.x86_64-linux.provisioning-scripts`: the built `bin/rotate-token` wrapper prepends the rclone store path to PATH. - `shellcheck -x` on the script and the test file: clean. - Sabotage runs, each reverted and the suite green again: removing the trailing-newline line, loosening the parser to drop `explicit_tls`, dropping `require_rclone`, dropping the second recipient from the recipient list, echoing the password to stderr, and dropping the mixed-format guard each failed exactly the test that pins that property. - Reviewed by codex (gpt-5.6-sol) and pi (gpt-oss-120b) on the first and final diffs. Applied: the trailing newline and byte comparison, control-character rejection in the parser, validation of the obscured token's shape, and the mixed-group refusal. Kept as-is after checking: an existing stanza with no final newline or trailing blank lines is accepted and rewritten in canonical form, and the parser does not single out the two Unicode line separators the Go writer refuses, since a stanza that writer produced cannot contain them. A pi claim of a doubled trailing newline is false: command substitution strips it and exactly one is added, which the byte comparison pins. Labels are unavailable to this token; none is warranted.
Adds a third secret format alongside raw-forgejo-token and credential-store-url: the complete [shared] rclone remote stanza that allod site config writes. Rotation decrypts the existing stanza to carry host and user forward, prompts once on a hidden prompt worded for a password, obscures it with rclone obscure - on stdin, and rebuilds the stanza byte-compatible with remoteStanza in allod/tools cmd/allod/site_config.go. A malformed existing stanza (not exactly one [shared] section with type/host/user/pass/explicit_tls) dies before any prompt. The format and a new site-check verify type are the smallest accommodation in the Forgejo-shaped token-group registry validator, with a comment explaining the mismatch and pointing at the open sibling-registry question. Verification advice prints allod site check, since rclone itself is not on the operator's PATH. rotate-token gets rclone as its own runtime input via wrapProgram in nix/provisioning-scripts.nix rather than reaching through the allod wrapper's PATH.

Tests add a mock rclone in the fixture bin dir whose obscure - reads the password on stdin and emits a deterministic marker, plus a curated rclone-less PATH fixture, covering dry-run listing, a live rotation that rewrites only pass, malformed-stanza and missing-rclone failures before any prompt, and recipient preservation. No docs/ or README page documents the existing two formats to extend; the format shapes are now documented in a comment at the top of scripts/rotate-token instead. flake.nix needed no changes — the provisioning-contract check's existing sandbox already lacks rclone (proving the missing-rclone test) and the tests' own mock and stubs cover everything else the new format needs.

Refs allod/nexus#42
Refs allod/nexus#42
Review findings applied on top of the byte-exact stanza comparison already recorded: parse_rclone_remote_stanza now excludes all control characters from host/user/pass, not just newline, matching allod site config's own validConfigValue rejection, so a stray tab or carriage return in a hand-written stanza dies loudly instead of being carried forward. stage_items validates that rclone obscure - printed exactly one base64url token before embedding it in the rebuilt stanza, rather than only checking for non-emptiness, closing a path where multi-line or control-character output could inject extra config lines. A registry group that mixes rclone-remote-stanza with either token format now dies before any prompt with assert_uniform_prompted_format, since the single prompted value cannot correctly serve as both a password and a token; group_prompt_label is simplified now that mixing cannot reach it.

Tests: the mock rclone's marker uses a hyphen instead of a colon so it fits the token shape rclone actually returns, with the fixture, expectations, and a new mixed.site group added for a test that the mixed-format guard dies before any prompt. Every assertion in the five existing new-format tests plus the new mixed-group test now ends in || return 1, because run_test runs each test as an if condition and bash suppresses set -e for everything inside an if condition including a called function's body, so only a function's last command was previously enforced. The literal prompt text assertion in the byte-rotation test is removed: bash's read -p never prints its prompt when stdin is not a terminal, so no piped test in this suite can observe it; the byte-for-byte stanza comparison already proves the piped value was obscured as a password rather than stored as a token. The rclone-less PATH fixture gained dirname, mktemp, cat, and tr, all of which the script and its nix stub need before reaching the rclone check.

Sabotaged and reverted six properties to prove the new self-enforcing tests actually catch a regression: dropping the rebuilt stanza's trailing newline, loosening the parser to accept a stanza missing explicit_tls, dropping the require_rclone guard, encrypting the fixture secret to one recipient only, echoing the piped password to stderr, and dropping the mixed-format guard — each produced the expected single FAIL line and no others, then was reverted and the full suite passed again at 36/36.

Refs allod/nexus#42
vnprc approved these changes 2026-09-10 16:47:25 +01:00
vnprc force-pushed agent/rotate-token-rclone-stanza from 945c9011b5 to df245148ef 2026-09-10 16:47:32 +01:00 Compare
vnprc merged commit df245148ef into master 2026-09-10 16:47:41 +01:00
vnprc deleted branch agent/rotate-token-rclone-stanza 2026-09-10 16:47:42 +01:00
Sign in to join this conversation.
No description provided.