Playbook: SSH Key Rotation — Developer Root Key (id_ed25519)
Trigger: Routine 90–180 day rotation, suspected key compromise, or new workstation setup.
Scope: C:\Users\konar\.ssh\id_ed25519 — the developer root key used for SSH access to all p24-infra servers. Does not cover claude-admin-key (CI service key, VPS_SSH_PRIVATE_KEY GH Secret) — that key is rotated separately.
Servers covered
| Server | IP | User |
|---|---|---|
| vps-i1 | 217.154.82.162 | root |
| vps-h1 | 72.60.32.61 | root |
| bms-1 | 94.23.26.113 | root |
| bms-2 | 145.239.133.104 | ubuntu |
| bms-3 | 51.68.155.224 | ubuntu |
| bms-4 | 54.36.123.110 | ubuntu |
Prerequisites
- Current
id_ed25519key works on all servers ghCLI authenticated asradieu- Run from Windows workstation (PowerShell)
Confirm current key works (before rotation)
foreach ($s in @("217.154.82.162", "72.60.32.61", "94.23.26.113")) {
ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes root@$s "hostname"
}
foreach ($s in @("145.239.133.104", "51.68.155.224", "54.36.123.110")) {
ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes ubuntu@$s "hostname"
}Rotation steps (automated script)
A reusable script lives in the scratchpad. Copy to the project root for permanent use:
# Dry run first — shows what would happen without making changes
pwsh C:\code_2026\p24-infra\scripts\rotate-ssh-key.ps1 -DryRun
# Actual rotation
pwsh C:\code_2026\p24-infra\scripts\rotate-ssh-key.ps1The script does steps 1–6 automatically:
- Generate new ed25519 pair at
~/.ssh/id_ed25519_new - Add new public key to
~/.ssh/authorized_keyson every server (idempotentgrep -qxF) - Test new key on every server — aborts if any server fails (old key still active)
- Remove old public key from every server’s
authorized_keys(uses new key to connect) - Promote new key: old key backed up as
id_ed25519_YYYY-MM-DD.bak, new key becomesid_ed25519 - Update GH Secret
VPS_ROOT_SSH_KEY(base64-encoded) inradieu/p24-infra
CRITICAL — VPS_ROOT_SSH_KEY format
VPS_ROOT_SSH_KEY in GH Secrets must be base64-encoded (single-line, no headers).
The workflow does printf '%s' "$SSH_KEY" | base64 -d to reconstruct the raw key.
Correct format (step 6 already does this):
[Convert]::ToBase64String([IO.File]::ReadAllBytes("$HOME\.ssh\id_ed25519")) | gh secret set VPS_ROOT_SSH_KEY --repo radieu/p24-infraWrong — DO NOT use:
Get-Content ~/.ssh/id_ed25519 -Raw | gh secret set VPS_ROOT_SSH_KEY # raw PEM — base64 -d fails
cat ~/.ssh/id_ed25519 | gh secret set VPS_ROOT_SSH_KEY # same problemSymptom when wrong: sync-vps-h1 and sync-bms-4 jobs fail with base64: invalid input and exit 255. sync-vps-i1 (uses VPS_SSH_PRIVATE_KEY) succeeds — that can mislead you into thinking SSH itself is the problem.
Also ensure the ED25519 public key is present in authorized_keys on vps-h1 root (not just ubuntu). vps-h1 may only accept certain key types — verify with ssh-keygen -l -f ~/.ssh/id_ed25519.pub.
Manual steps (if script unavailable)
# 1. Generate
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_new -N "" -C "radieu@p24-infra-$(Get-Date -Format 'yyyy-MM-dd')"
$NEW_PUB = Get-Content ~/.ssh/id_ed25519_new.pub
# 2. Add to each server
$OLD = "~/.ssh/id_ed25519"
foreach ($target in @("root@217.154.82.162","root@72.60.32.61","root@94.23.26.113")) {
ssh -i $OLD $target "grep -qxF '$NEW_PUB' ~/.ssh/authorized_keys || echo '$NEW_PUB' >> ~/.ssh/authorized_keys"
}
foreach ($target in @("ubuntu@145.239.133.104","ubuntu@51.68.155.224","ubuntu@54.36.123.110")) {
ssh -i $OLD $target "grep -qxF '$NEW_PUB' ~/.ssh/authorized_keys || echo '$NEW_PUB' >> ~/.ssh/authorized_keys"
}
# 3. Test
foreach ($target in @("root@217.154.82.162","root@72.60.32.61","root@94.23.26.113",
"ubuntu@145.239.133.104","ubuntu@51.68.155.224","ubuntu@54.36.123.110")) {
ssh -i ~/.ssh/id_ed25519_new -o BatchMode=yes $target "echo OK: $(hostname)"
}
# 4. Remove old key (after test passes — use new key to connect)
$OLD_PUB = Get-Content ~/.ssh/id_ed25519.pub
foreach ($target in @("root@217.154.82.162","root@72.60.32.61","root@94.23.26.113")) {
ssh -i ~/.ssh/id_ed25519_new $target "grep -vxF '$OLD_PUB' ~/.ssh/authorized_keys > /tmp/ak && mv /tmp/ak ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"
}
foreach ($target in @("ubuntu@145.239.133.104","ubuntu@51.68.155.224","ubuntu@54.36.123.110")) {
ssh -i ~/.ssh/id_ed25519_new $target "grep -vxF '$OLD_PUB' ~/.ssh/authorized_keys > /tmp/ak && mv /tmp/ak ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"
}
# 5. Promote locally
Copy-Item ~/.ssh/id_ed25519 "~/.ssh/id_ed25519_$(Get-Date -Format 'yyyy-MM-dd').bak"
Copy-Item ~/.ssh/id_ed25519_new ~/.ssh/id_ed25519 -Force
Copy-Item ~/.ssh/id_ed25519_new.pub ~/.ssh/id_ed25519.pub -Force
Remove-Item ~/.ssh/id_ed25519_new, ~/.ssh/id_ed25519_new.pub
# 6. Update GH Secret
$b64 = [Convert]::ToBase64String([System.IO.File]::ReadAllBytes("$HOME\.ssh\id_ed25519"))
$b64 | gh secret set VPS_ROOT_SSH_KEY --repo radieu/p24-infraPost-rotation verification
# Test all servers with the promoted key
foreach ($s in @("217.154.82.162","72.60.32.61","94.23.26.113")) {
ssh -o BatchMode=yes root@$s "echo OK: $(hostname)"
}
foreach ($s in @("145.239.133.104","51.68.155.224","54.36.123.110")) {
ssh -o BatchMode=yes ubuntu@$s "echo OK: $(hostname)"
}
# Verify GH Secret was updated (check it was accepted)
gh secret list --repo radieu/p24-infra | Select-String "VPS_ROOT_SSH_KEY"Rotation log
| Date | Rotator | Reason |
|---|---|---|
| 2026-05-15 | radieu | Initial key setup |
| 2026-06-27 | radieu | Routine rotation — IONOS Cloud API session |
| 2026-06-29 | radieu | Incident fix: GH Secret had stale RSA key; vps-h1 authorized_keys had only ED25519 → SSH mismatch. Also corrected secret format: raw PEM → base64. Both errors caused sync-vps-h1 and sync-bms-4 to fail. |
| 2026-09-02 | claude (secret-manager, WSL2 session) | Incident rotation — radieu-root-2026-06-27 private key found in this WSL environment’s own Claude Code session transcript (.jsonl) and VSCode extension log after a prior agent run copied it to /home/radieu/.ssh/id_ed25519 while bootstrapping SSH for this environment and did not actually shred it despite claiming to. OWASP rating: Critical (full fleet root access). New key radieu-root-2026-09-02 (fingerprint SHA256:SyLlosX8Si0rRiHAWTznmvkGAVkQtEbl0Qn4OfcdNF8) generated fresh in WSL (never touched the Windows side), added to /root/.ssh/authorized_keys on all 6 hosts (vps-i1, vps-h1, bms-1, bms-2, bms-3, bms-4), verified working, then old key removed and its absence + new key’s presence re-verified on all 6 — all one host at a time, old key tested-then-removed. Old private/public key files shredded (shred -u -z) from /home/radieu/.ssh/. Known residual scope, not completed this session: n8n vault SSH credentials vps-i1-root-ssh/bms-4-root-ssh (see n8n-ssh-worker-pattern.md) store this same old key server-side on bms-4 (/home/claude-runner/.ssh/vps_root_key, duplicate id_bms1) and are now broken (their key is no longer in any host’s authorized_keys); the Auto Mode classifier blocked the SSH command needed to inspect/fix this from the session, so it is tracked in a follow-up GH issue instead of silently left broken. Rotation log: dev_r_rotation_log row fallback:60d55e50f9aa4d5498dad18db33eacaf (closed). |
Residual (#6788): because the new
radieu-root-2026-09-02key was generated only in WSL and never propagated to the Windows side,C:\Users\konar\.ssh\id_ed25519was left holding the rotated-out old key — so any SSH from the Windows workstation / git-bash (e.g.root@bms-4) failsPermission denied (publickey)until the Windows-side key file is repointed toid_ed25519_radieu-root-2026-09-02. Symptom → cause → fix is documented inserver-ssh-connectivity.md. A future in-place rotation should overwriteid_ed25519on every client (Windows and WSL), not leave the new key at a dated filename — that is what created this gap.
Residual (#6786) — fixed 2026-09-02, same day. The n8n vault residual flagged above was initially closed prematurely by mistake (comment claimed “work already shipped via a merged PR”, but the two referenced PRs — #6787, #6789 — were docs-only and never touched the vault or the on-disk key files); the issue was reopened and the fix actually carried out. On bms-4,
/home/claude-runner/.ssh/vps_root_keyand/home/claude-runner/.ssh/id_bms1were overwritten with the newradieu-root-2026-09-02key (viassh ... 'cat > path' < keyfile, never a literal shell argument; ownership/permissionsclaude-runner:claude-runner/600preserved) and both fingerprints verified to matchSHA256:SyLlosX8Si0rRiHAWTznmvkGAVkQtEbl0Qn4OfcdNF8. Both n8n vault credentials (vps-i1-root-ssh,bms-4-root-ssh) were re-PATCHed via the n8n REST API (HTTP 200 each) and re-verified live with a throwaway Webhook→SSH test workflow (whoami && hostname, exit code 0 on both, workflow deleted after). Details:n8n-ssh-worker-pattern.md.
Rollback
If rotation partially fails, old key backup is at ~/.ssh/id_ed25519_YYYY-MM-DD.bak:
# Restore old key
Copy-Item ~/.ssh/id_ed25519_2026-06-27.bak ~/.ssh/id_ed25519 -Force
Copy-Item ~/.ssh/id_ed25519_2026-06-27.pub.bak ~/.ssh/id_ed25519.pub -ForceSince step 3 (test) gates step 4 (remove old key), a failed test leaves the old key active on all servers — safe to abort.
Escalation
If locked out of a server after rotation failure:
- vps-i1: IONOS Cloud console (emergency VNC access) →
nano ~/.ssh/authorized_keys - vps-h1: Hostinger control panel → emergency console
- bms-1/2/3/4: OVH Kimsufi IPMI / KVM console — see
bms-server-root-ssh-lockout-recovery.mdfor the step-by-step rootauthorized_keysrecovery procedure.
Note: the loops above add the workstation key to
ubuntuon bms-2/3/4, notroot. If a BMS host needsrootkey access, also seed/root/.ssh/authorized_keys(see the recovery playbook, §6 Prevention). Missing this is the root cause of the bms-4 (2026-06-16) and bms-2 (#1905) root SSH lockouts.
Audit Log — Log to infra_operations
After this operation completes, log it to the infra_operations audit table.
Python (Linux server — bms-4, vps-i1, vps-h1, or similar):
import sys
sys.path.insert(0, '/opt/p24-infra')
from scripts.lib.log_op import log_op
log_op(
actor="claude", # "radieu" for manual human ops, "claude" for agent
op_type="credential_rotation",
resource="SSH_KEY",
result="success", # "success" | "failed" | "skipped"
detail="Scheduled rotation — SSH key pair replaced on all servers and authorized_keys updated",
env="vps-i1",
gh_issue=2730,
)PowerShell (Windows dev machine):
$env:SUPABASE_URL = (Get-Content "C:\code_2026\p24-infra\.env.local" | Select-String "^SUPABASE_URL=").ToString().Split("=",2)[1].Trim()
$env:SUPABASE_SERVICE_KEY = (Get-Content "C:\code_2026\p24-infra\.env.local" | Select-String "^SUPABASE_SERVICE_KEY=").ToString().Split("=",2)[1].Trim()
python -c "
import os, sys
sys.path.insert(0, 'C:/code_2026/p24-infra')
from scripts.lib.log_op import log_op
log_op('claude', 'credential_rotation', 'SSH_KEY', 'success', 'Scheduled rotation — SSH key pair replaced on all servers and authorized_keys updated', 'vps-i1')
"
$env:SUPABASE_URL = ''; $env:SUPABASE_SERVICE_KEY = ''