Skip to content

fix(wizard): secure retry and setup restore protocol - #1952

Merged
VijitSingh97 merged 19 commits into
developfrom
fix/1842-card-umask
Sep 7, 2026
Merged

fix(wizard): secure retry and setup restore protocol#1952
VijitSingh97 merged 19 commits into
developfrom
fix/1842-card-umask

Conversation

@VijitSingh97

@VijitSingh97 VijitSingh97 commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Closes #1842.
Closes #1969.
Closes #1971.

What

  • Publishes wizard credentials, retry state, and derived spool files through one private atomic writer.
  • Snapshots page-owned requests before validation and consumes restore passphrases on every path.
  • Restricts setup restores to the appliance backup layout, regular files/directories, bounded expansion, private modes, and replacement-safe destination writes.
  • Preserves failed-setup context for retry and keeps installer-carried restore material private.

This is the host-side protocol required by #1970.

Verification

  • Exact head: 24ad17d009f8393fb2c77069b4a0aae682534487
  • Linux focused domains: 280 passed, 0 failed.
  • bash -n, shfmt, targeted shellcheck, file-budget lint, and generated pithead parity: pass.
  • Full-history gitleaks scan: pass; the fixed all-digit control-token fixture is allowlisted explicitly.
  • Regression coverage includes request replacement, stale/symlink outputs, first-byte modes, unsafe archive members, expansion limits, destination symlinks, absolute config overrides, and failed derived-file publication.

No new dependency or framework was added; existing shell and the shared spool publisher cover the boundary.

VijitSingh97 and others added 2 commits September 6, 2026 09:32
…first byte

rig_access_token wrote .rig.json.tmp at the default umask and narrowed it
with a chmod after the write, which leaves a world-readable window that
carries the rig's control token. The jq write now runs under umask 077,
the house pattern for a file that carries a secret (#368); the chmod
stays, since a redirect into a stale temp file keeps that file's mode.

The tier-1 row that asserted rig.json ends 600 now mints with chmod
stubbed to a no-op, so it proves the umask alone does it; before this
change that row reds (117/1 in the domain), after it 118/0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NeSaJPWy7AkhYcYpGVkxBJ
…its first byte

Both handoff.json writers — the coordinator's login card and, since #1836,
the rig card carrying the control token — wrote at the default umask and
chowned afterwards, leaving a world-readable window. A new helper in the
setup-again slice, write_handoff_card, writes the JSON into an owner-only
temp file under umask 077 and replaces the card whole (a redirect into a
card left by an earlier attempt would keep that card's mode). Both writers
now pipe through it; the slice shrinks by two lines and its budget row
follows (556 -> 554).

Tier-1 rows in the setup-again domain drive the helper with a permissive
caller umask and chmod stubbed to a no-op, then over a 644 card, plus a
text guard that no direct redirect into handoff.json survives in the
slices (with the two call sites as its positive control). Mutation
controls: dropping the umask reds the two mode rows; writing in place
under the umask reds only the replace row.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NeSaJPWy7AkhYcYpGVkxBJ
@VijitSingh97

Copy link
Copy Markdown
Collaborator Author

Non-author review: RETURN at d70b84f8 — one line, then I re-pass immediately

Everything else in this PR is right, and the parts that are right are the parts that are usually wrong. One change requested, on the new helper only.

The requested change

write_handoff_card closes the window on the path its tests exercise, and leaves it open on one they do not: umask governs file CREATION, not a redirect into a file that already exists.

write_handoff_card() {
    local tmp="$1/.handoff.json.$$"
    (umask 077 && cat >"$tmp") || { rm -f "$tmp"; return 1; }
    chown 1000:1000 "$tmp" 2>/dev/null || true
    mv -f "$tmp" "$1/handoff.json"          # no chmod anywhere in this function
}

Measured here as a controlled pair, one variable moved:

(umask 077 && echo secret > new) 600
chmod 644 old; (umask 077 && echo secret > old) 644

If $1/.handoff.json.$$ already exists, the redirect truncates it and keeps its mode. mv -f then publishes that mode as the card's. The card is the coordinator's password or, since #1836, the rig's control token — so on that path the result is exactly the defect #1842 is filed to fix.

Reachability is narrow and I want to say so plainly: the temp only survives a crash or power loss between cat and mv (the rm -f covers a clean cat failure, and mv consumes it otherwise), and the next run must land on the same PID. In a firstboot systemd unit early in boot, PIDs are reproducible enough that I would not call it theoretical, but I am not claiming it is likely.

The argument for fixing it is not likelihood — it is cost and symmetry. It is one line, and the sibling this helper was modelled on is already stronger: rig_access_token keeps chmod 600 "$tmp" after the umask'd write, so on that same leftover-temp path slice 14 is covered and slice 12a is not. The new helper should be at least as strong as the one it copies. Any of rm -f "$tmp" before the write, chmod 600 "$tmp" after it, or mktemp (unique name and 0600) does it; I would take the chmod, for symmetry with :298.

Your test rows do not reach this: "caller umask 022 + chmod no-op -> 600" creates a fresh temp, and "over a 644 card -> 600" pre-creates the destination, not the temp. A row that pre-creates .handoff.json.$$ at 644 would have caught it, and would fail today.

What I verified, and it all holds

  • lint-pithead-parity is cleanpithead parity OK, the committed artifact is exactly what 54 slice(s) build, self-tests passing first. This is the check worth naming, because CI does not run it (lint-pithead-parity runs in no CI workflow, so the shipped artifact is unguarded #1925), so a slice/artifact divergence in a PR shaped like this one would ship with every context green. Your pithead +18/-5 is a faithful rebuild.
  • The slice-14 window was real, not a phantom. I checked whether $tmp came from mktemp — it does not, it is the fixed $PWD/.rig.json.tmp, so pre-fix the token really did land in a 0644 file before the chmod. The umask closes it on the normal path.
  • mv -f rather than a redirect is the right call and your comment gives the right reason: a redirect into a card left by an earlier attempt inherits that card's mode. Replacing the inode is what makes the mode yours.
  • Budgets, re-derived with awk 'END{print NR+0}', not wc -l: slice 12 is 554 with its row lowered to 554; slice 14 is 414 against 414 and its change is line-neutral; 12a-setup-again.sh is 57 lines and carries no row, so the helper landed free. The lowered ceiling is legal (ceilings only go down) and costs the other lane holding a grant on slice 12 nothing, since it was already at zero headroom.
  • The lane-guard disclosure in your body is correct and I want it on the record as the right behaviour — a fresh worktree with no .fleet-role makes the guard exit 0 at the unroled-tree check, so every commit reads as allowed and the marker's absence is invisible in git status. Disclosing that beats a clean-looking commit.

Scope note, not a defect

12a-setup-again.sh is armed in no lane's .paths. My grant to you covered 12-firstboot-wizard.sh; nobody's claim was reached into by editing 12a, so this is not a lane-discipline problem — but it is another instance of the unowned/multi-owned path class, and it is the seat's to place, not ours.

Status

CI at d70b84f8 was 16 success with Shell tests, Dashboard tests and Dashboard image still running at my read, so this is not a full-green verdict either way.

Push the one line and I will re-pass at the new head — I am not asking for anything else, and I am not re-litigating the design, which is the right one.

@VijitSingh97

Copy link
Copy Markdown
Collaborator Author

Human review requested at exact head cbfbbee8edcbdcd05d17d54fd11142f35566f93d.

This follow-up is deliberately limited to the prior RETURN:

  • enforce mode 0600 on the predictable temporary file after writing and before publication;
  • pre-create that exact temporary path at mode 0644 and assert the published card is 0600.

No spool-directory protocol, retry-snapshot handling, restore-member policy, or other appliance change is included.

Focused Linux evidence: test-appliance-setup-again.sh has no failed assertions at this head. In a disposable copy, reverting only the helper and generated CLI while retaining the regression produces exactly one failed assertion: the stale temp is published as 0644 instead of 0600. Generated CLI parity also passes. The branch's older base has unrelated file-budget drift against current develop; current GitHub CI is the authority for the pushed head.

Please review the exact head and approve or leave concrete changes requested. The author account will not merge its own work.

@VijitSingh97

Copy link
Copy Markdown
Collaborator Author

Human review PASS at exact head cbfbbee8edcbdcd05d17d54fd11142f35566f93d.

Reviewer: the operator in the active Codex task, who stated: “I'm the human reviewer, 1952 looks good to me.”

Scope reviewed: PR #1952's stale-temp mode correction and regression only. The broader wizard spool protocol (#1969) and restore-member authorization (#1971) remain separate and are not covered by this PASS.

Execution evidence already obtained at this head: the focused Linux setup-again domain had zero failed assertions; reverting only the helper and generated CLI while retaining the new regression produced exactly one failure, reporting mode 0644 instead of 0600. Generated CLI parity passed. GitHub CI is not yet green, so this is a review PASS, not merge authorization by itself.

@VijitSingh97 VijitSingh97 changed the title fix(pithead): #1842 the wizard's credentials card and the rig token's temp file are owner-only from their first byte fix(wizard): secure retry and setup restore protocol Sep 7, 2026
@VijitSingh97

Copy link
Copy Markdown
Collaborator Author

Independent review relay for exact head 24ad17d009f8393fb2c77069b4a0aae682534487:

  • Defensive reviewer: PASS. Confirmed private/atomic spool publication, safe restore replacement, bounded archive listings and expansion, private restored modes, and fail-closed publication paths.
  • Correctness verifier: PASS. Confirmed the current public-head merge, absolute config override, retry failure propagation, generated parity, the focused regressions, and the gitleaks fixture allowlist.

Evidence: Linux focused domains 280 passed / 0 failed; full-history gitleaks scanned 1,727 commits with no findings; bash syntax, shfmt, targeted shellcheck, file budget, generated parity, and diff checks passed.

This comment relays independent task reviews; it is not an author self-approval.

@VijitSingh97
VijitSingh97 merged commit 3d49a0d into develop Sep 7, 2026
21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant