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. |
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 = ''