Harden the SSH-key rotation script suite to a uniform posture #18

Open
opened 2026-07-20 16:33:55 +01:00 by vnprc-agent · 0 comments

Bring the three SSH-key rotation scripts (forge-ssh-key, nexus-host-key, vm-ssh-host-key) and the shared rotation-common.sh up to a single uniform safety posture, so rotating any key is equally safe, testable, and previewable regardless of which script runs. Today the three form an uneven maturity ladder — nexus-host-key is the strongest; the other two lag on host-key pinning, state-shape validation, dry-run, and testability seams.

Primary goals:

  • Uniform security posture — pin SSH host-key verification and single-source the hardened SSH options across all three scripts.
  • Operational safety parity — dry-run preview and a robust reload-wait on every script, not just some.
  • Testability and consistency seams — a stubbable age seam and consistent dirty-tree guards.
  • Coverage and docs — no silently-unwired tests; document the env overrides each script honors.
  • Token registry lifecycle — durable-alias identity and metadata commands for the rotate-token registry.

Security hardening

  • forge-ssh-key: pin the VM-side git-over-SSH verification. It runs ssh … -o StrictHostKeyChecking=no to the Forge (scripts/forge-ssh-key:182,253), so the VM auto-accepts any presented host key on the exact step that proves the new key before retire drops the old one — a MITM window. Needs a VM-side known_hosts / Forge-host-key-pinning story that survives rebuilds. Design item.
  • forge-ssh-key: harden the host-side git-over-SSH verify to StrictHostKeyChecking=yes / -F /dev/null, matching nexus-host-key.
  • rotation-common: extract a shared pinned-SSH-options builder. The pinned posture (-o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=…) is copy-pasted (scripts/vm-ssh-host-key:326-327, scripts/forge-ssh-key SSH_OPTS) rather than single-sourced in scripts/lib/rotation-common.sh; it encodes a security invariant, so drift between copies is a real risk.

State-shape validation (model: scripts/nexus-host-key:90 assert_nexus_key_json_shape)

  • vm-ssh-host-key: JSON state-shape validation on stage/activate/retire.
  • vm-ssh-host-key: runtime retire proof — after re-encryption, assert the new key decrypts the target's secrets and the old public key is absent from the recipient graph.

Operational safety parity

  • Add --dry-run to nexus-host-key and vm-ssh-host-key (forge-ssh-key has it: scripts/forge-ssh-key:44,429,458). Previews planned changes, touches no key material.
  • vm-ssh-host-key: replace the fixed sleep 2 after sshd reload (scripts/vm-ssh-host-key:346) with a bounded ssh-keyscan poll for the staged key, so a slow reload no longer aborts a legitimate rotation.

Testability and consistency

  • vm-ssh-host-key: route age through an overridable AGE="${AGE:-age}" seam (siblings: scripts/nexus-host-key:26, scripts/forge-ssh-key:26); it currently calls age directly (e.g. scripts/vm-ssh-host-key:144), lacking the stub seam its suite needs.
  • Standardize the dirty-tree guards. nexus-host-key (scripts/nexus-host-key:108) and vm-ssh-host-key (scripts/vm-ssh-host-key:111) assert only secrets clean, yet both read inventory specs; forge-ssh-key additionally guards inventory. Either add the inventory clean-check to the two, or document why they tolerate a dirty inventory. Conscious call.

Coverage and docs

  • flake.nix: guarantee every tests/*.sh is wired into the run list. checked_scripts (flake.nix:107) and the explicit bash tests/… run list (flake.nix:121-129) are hand-maintained parallel lists; a test added to the first but not the second passes CI while never running. Auto-discover tests/*.sh or add a meta-check.
  • forge-ssh-key: document FORGE_SSH_KEY_TMPDIR / FORGE_SSH_KEY_ALLOW_NON_TMPFS in usage() — honored (scripts/forge-ssh-key:360,382) but undocumented, unlike the sibling scripts.

Token registry (rotate-token)

  • Improve token-registry identity and provider-name lifecycle: key on the durable registry_alias, add commands to show/verify/update provider metadata without touching token values, and detect ambiguous provider selectors. Forge UI token names are not editable and rotation keeps the name, so names are poor lifecycle markers.

Scope

In: the three rotation scripts, scripts/lib/rotation-common.sh, the provisioning-contract flake.nix check, and rotate-token registry ergonomics. Each task is independently landable. Out: broader VM-provisioning-script standardization, tracked in #17.

Bring the three SSH-key rotation scripts (`forge-ssh-key`, `nexus-host-key`, `vm-ssh-host-key`) and the shared `rotation-common.sh` up to a single uniform safety posture, so rotating any key is equally safe, testable, and previewable regardless of which script runs. Today the three form an uneven maturity ladder — `nexus-host-key` is the strongest; the other two lag on host-key pinning, state-shape validation, dry-run, and testability seams. Primary goals: - **Uniform security posture** — pin SSH host-key verification and single-source the hardened SSH options across all three scripts. - **Operational safety parity** — dry-run preview and a robust reload-wait on every script, not just some. - **Testability and consistency seams** — a stubbable `age` seam and consistent dirty-tree guards. - **Coverage and docs** — no silently-unwired tests; document the env overrides each script honors. - **Token registry lifecycle** — durable-alias identity and metadata commands for the `rotate-token` registry. ### Security hardening - [ ] `forge-ssh-key`: pin the VM-side git-over-SSH verification. It runs `ssh … -o StrictHostKeyChecking=no` to the Forge (`scripts/forge-ssh-key:182,253`), so the VM auto-accepts any presented host key on the exact step that proves the new key before retire drops the old one — a MITM window. Needs a VM-side known_hosts / Forge-host-key-pinning story that survives rebuilds. **Design item.** - [ ] `forge-ssh-key`: harden the host-side git-over-SSH verify to `StrictHostKeyChecking=yes` / `-F /dev/null`, matching `nexus-host-key`. - [ ] `rotation-common`: extract a shared pinned-SSH-options builder. The pinned posture (`-o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=…`) is copy-pasted (`scripts/vm-ssh-host-key:326-327`, `scripts/forge-ssh-key` SSH_OPTS) rather than single-sourced in `scripts/lib/rotation-common.sh`; it encodes a security invariant, so drift between copies is a real risk. ### State-shape validation (model: `scripts/nexus-host-key:90` `assert_nexus_key_json_shape`) - [ ] `vm-ssh-host-key`: JSON state-shape validation on stage/activate/retire. - [ ] `vm-ssh-host-key`: runtime retire proof — after re-encryption, assert the new key decrypts the target's secrets and the old public key is absent from the recipient graph. ### Operational safety parity - [ ] Add `--dry-run` to `nexus-host-key` and `vm-ssh-host-key` (`forge-ssh-key` has it: `scripts/forge-ssh-key:44,429,458`). Previews planned changes, touches no key material. - [ ] `vm-ssh-host-key`: replace the fixed `sleep 2` after sshd reload (`scripts/vm-ssh-host-key:346`) with a bounded `ssh-keyscan` poll for the staged key, so a slow reload no longer aborts a legitimate rotation. ### Testability and consistency - [ ] `vm-ssh-host-key`: route `age` through an overridable `AGE="${AGE:-age}"` seam (siblings: `scripts/nexus-host-key:26`, `scripts/forge-ssh-key:26`); it currently calls `age` directly (e.g. `scripts/vm-ssh-host-key:144`), lacking the stub seam its suite needs. - [ ] Standardize the dirty-tree guards. `nexus-host-key` (`scripts/nexus-host-key:108`) and `vm-ssh-host-key` (`scripts/vm-ssh-host-key:111`) assert only secrets clean, yet both read inventory specs; `forge-ssh-key` additionally guards inventory. Either add the inventory clean-check to the two, or document why they tolerate a dirty inventory. **Conscious call.** ### Coverage and docs - [ ] `flake.nix`: guarantee every `tests/*.sh` is wired into the run list. `checked_scripts` (`flake.nix:107`) and the explicit `bash tests/…` run list (`flake.nix:121-129`) are hand-maintained parallel lists; a test added to the first but not the second passes CI while never running. Auto-discover `tests/*.sh` or add a meta-check. - [ ] `forge-ssh-key`: document `FORGE_SSH_KEY_TMPDIR` / `FORGE_SSH_KEY_ALLOW_NON_TMPFS` in `usage()` — honored (`scripts/forge-ssh-key:360,382`) but undocumented, unlike the sibling scripts. ### Token registry (`rotate-token`) - [ ] Improve token-registry identity and provider-name lifecycle: key on the durable `registry_alias`, add commands to show/verify/update provider metadata without touching token values, and detect ambiguous provider selectors. Forge UI token names are not editable and rotation keeps the name, so names are poor lifecycle markers. ### Scope In: the three rotation scripts, `scripts/lib/rotation-common.sh`, the provisioning-contract `flake.nix` check, and `rotate-token` registry ergonomics. Each task is independently landable. Out: broader VM-provisioning-script standardization, tracked in #17.
Sign in to join this conversation.
No description provided.