320 lines
13 KiB
Markdown
320 lines
13 KiB
Markdown
# SSH hardening transaction implementation plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Make `boot --server` refuse unsafe SSH hardening and atomically roll back failed validation or reloads.
|
|
|
|
**Architecture:** Keep production logic in the standalone `boot` file because it runs before the repository exists. Exercise the public `boot --server` command through a PTY, a temporary filesystem root, and PATH command adapters; never call private functions directly or touch host SSH state.
|
|
|
|
**Tech Stack:** Bash 5, Python 3 standard library for the PTY driver, OpenSSH/systemd command adapters, Panama contract runner.
|
|
|
|
**Spec:** `docs/superpowers/specs/2026-08-27-secure-bootstrap-privileged-installation-design.md`
|
|
|
|
## Global constraints
|
|
|
|
- Preserve the public `boot --server` command and the non-root desktop bootstrap path.
|
|
- Do not reload a real SSH service, edit `/etc/ssh`, create a real account, or run a live privileged check.
|
|
- A missing or unsafe target key keeps root/password access unchanged and continues bootstrap.
|
|
- Candidate validation or reload failure restores the prior drop-in and stops before clone/install handoff.
|
|
- Exact safe state is target ownership plus `.ssh` mode `0700` and `authorized_keys` mode `0600`; symlinks, root UID, relative homes, blank/comment-only keys, and foreign ownership are refused.
|
|
- `PANAMA_BOOT_FIXTURE_ROOT` is accepted only by a real non-root process whose stubbed `id -u` reports root. Real root plus that variable must fail closed.
|
|
- The new public contract is hermetic and must run under `panama test --safe`.
|
|
- Preserve the user's unstaged `/home/gib/.local/share/Panama/config/bash/.bashrc` change outside this worktree.
|
|
|
|
## File map
|
|
|
|
- `boot`: account/key preconditions, test-only filesystem adapter, SSH unit detection, atomic drop-in transaction, rollback.
|
|
- `tests/setup/root-server-bootstrap-contract`: PTY and stateful command/filesystem fixture for the real public command.
|
|
- `tests/contracts.manifest`: one `hermetic` entry for the new executable contract.
|
|
- `tests/setup/boot-contract`: retain the non-root clone/handoff behavior; adjust only if the later verified-bootstrap plan changes it.
|
|
- `tests/setup/role-contract`: retain role/stage assertions.
|
|
- `README.md`: describe key-gated transactional hardening without claiming a live reload was tested.
|
|
|
|
---
|
|
|
|
### Task 1: Gate hardening on a verified login path
|
|
|
|
**Files:**
|
|
- Create: `tests/setup/root-server-bootstrap-contract`
|
|
- Modify: `boot:52-115`
|
|
- Modify: `tests/contracts.manifest`
|
|
- Test: `tests/setup/root-server-bootstrap-contract`
|
|
|
|
**Interfaces:**
|
|
- Consumes: public `boot --server`, `PANAMA_BOOT_FIXTURE_ROOT`, PTY answers `gib`, password already set, and `Y` for hardening.
|
|
- Produces: `system_path ABSOLUTE_PATH`, `safe_authorized_keys USER HOME`, and `harden_server_ssh USER HOME`; later tasks extend the last function with the transaction.
|
|
|
|
- [ ] **Step 1: Build the public fixture and write the failing precondition table**
|
|
|
|
Create a checked temporary tree with `root/etc/ssh/sshd_config.d`, `root/root/.ssh`, `root/home/gib/.ssh`, `bin`, `calls`, and a stub cloned installer. Use Python's `pty.openpty()` to start:
|
|
|
|
```python
|
|
env = {
|
|
**os.environ,
|
|
"PATH": f"{stub_dir}:/usr/bin:/bin",
|
|
"PANAMA_BOOT_FIXTURE_ROOT": fixture_root,
|
|
"PANAMA_PATH": f"{fixture_root}/home/gib/.local/share/Panama",
|
|
"HOME": f"{fixture_root}/root",
|
|
}
|
|
process = subprocess.Popen(
|
|
["bash", boot, "--server"],
|
|
stdin=slave,
|
|
stdout=slave,
|
|
stderr=slave,
|
|
env=env,
|
|
start_new_session=True,
|
|
)
|
|
os.write(master, b"gib\nY\n")
|
|
```
|
|
|
|
PATH stubs must log one shell-escaped argv vector per line. `id -u` with no username reports `0`; `id -u root` reports `0`; `id -u gib` reports `1000`; `id -nG gib` prints `gib wheel`; `passwd -S gib` prints `gib PS`; `getent passwd gib` prints the logical absolute home `/home/gib`; `runuser` materializes the clone/install handoff without changing users. `boot` resolves that logical home beneath the fixture root through `system_path`. Stub `stat` for fixture ownership metadata plus `dnf`, `git`, `sshd`, and `systemctl`; any unexpected command exits `97`.
|
|
|
|
Run one table row per unsafe state:
|
|
|
|
```text
|
|
missing
|
|
empty
|
|
comment-only
|
|
ssh-directory-symlink
|
|
authorized-keys-symlink
|
|
directory-wrong-mode
|
|
file-wrong-mode
|
|
directory-wrong-owner
|
|
file-wrong-owner
|
|
root-target-account
|
|
relative-home
|
|
```
|
|
|
|
For every row assert status `0`, a diagnostic containing `SSH hardening unavailable`, no `sshd -t`, no `systemctl reload`, no drop-in change, and a recorded install handoff. Add safe existing-key and safe root-key-copy rows that currently reach the unsafe direct-write path and therefore fail the new expected command ordering.
|
|
|
|
- [ ] **Step 2: Run the contract and confirm the red behavior**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
bash -n tests/setup/root-server-bootstrap-contract
|
|
tests/setup/root-server-bootstrap-contract
|
|
```
|
|
|
|
Expected: nonzero findings showing current `boot` writes/reloads without the required key checks and has no fixture-root support.
|
|
|
|
- [ ] **Step 3: Add the guarded filesystem adapter and key checks**
|
|
|
|
Add these shapes near the root branch, using `stat -Lc` only after rejecting symlinks:
|
|
|
|
```bash
|
|
BOOT_ROOT="${PANAMA_BOOT_FIXTURE_ROOT:-}"
|
|
if [[ -n "$BOOT_ROOT" && "$EUID" -eq 0 ]]; then
|
|
echo "boot: PANAMA_BOOT_FIXTURE_ROOT is test-only" >&2
|
|
exit 1
|
|
fi
|
|
|
|
system_path() {
|
|
local path="$1"
|
|
[[ "$path" == /* ]] || return 2
|
|
printf '%s%s\n' "$BOOT_ROOT" "$path"
|
|
}
|
|
|
|
safe_authorized_keys() {
|
|
local username="$1" user_home="$2" uid ssh_dir keys
|
|
uid="$(id -u "$username")" || return 1
|
|
[[ "$uid" =~ ^[0-9]+$ && "$uid" != 0 && "$user_home" == /* ]] || return 1
|
|
ssh_dir="$user_home/.ssh"
|
|
keys="$ssh_dir/authorized_keys"
|
|
[[ -d "$ssh_dir" && ! -L "$ssh_dir" && -f "$keys" && ! -L "$keys" ]] || return 1
|
|
[[ "$(stat -Lc '%u:%a' "$ssh_dir")" == "$uid:700" ]] || return 1
|
|
[[ "$(stat -Lc '%u:%a' "$keys")" == "$uid:600" ]] || return 1
|
|
grep -qEv '^[[:space:]]*(#|$)' "$keys"
|
|
}
|
|
```
|
|
|
|
Resolve the target home, `/root/.ssh/authorized_keys`, and `/etc/ssh/sshd_config.d` through `system_path`. A copyable root key must be a non-symlinked regular file owned by UID 0, mode `0600`, with at least one nonblank/non-comment line. Never overwrite an existing `authorized_keys`. When it is absent, either create a missing `.ssh` or require an existing `.ssh` to already be a real directory owned by the target UID with mode `0700`; then copy only the key and apply ownership/mode to those two paths. Never use `chown -R`. If `safe_authorized_keys` still fails, print the unavailable message and skip the prompt/transaction.
|
|
|
|
- [ ] **Step 4: Add the hermetic manifest entry and run focused checks**
|
|
|
|
Insert the sorted manifest line with a directly preceding comment:
|
|
|
|
```text
|
|
# Root bootstrap runs entirely against a temporary filesystem and PATH adapters.
|
|
hermetic tests/setup/root-server-bootstrap-contract
|
|
```
|
|
|
|
Run:
|
|
|
|
```bash
|
|
bash -n boot tests/setup/root-server-bootstrap-contract
|
|
tests/setup/contract-manifest-contract
|
|
tests/setup/root-server-bootstrap-contract
|
|
tests/setup/boot-contract
|
|
tests/setup/role-contract
|
|
./bin/panama test --safe root-server-bootstrap
|
|
git diff --check
|
|
```
|
|
|
|
Expected: all pass; the public fixture proves unsafe keys do not invoke `sshd` or reload while bootstrap still hands off.
|
|
|
|
- [ ] **Step 5: Commit the precondition gate**
|
|
|
|
```bash
|
|
git add boot tests/setup/root-server-bootstrap-contract tests/contracts.manifest
|
|
git commit -m "Fix: Gate SSH hardening on a verified key"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Make the drop-in transaction recoverable
|
|
|
|
**Files:**
|
|
- Modify: `boot:42-137`
|
|
- Modify: `tests/setup/root-server-bootstrap-contract`
|
|
- Test: `tests/setup/root-server-bootstrap-contract`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `safe_authorized_keys`, `system_path`, the fixture's `sshd`/`systemctl` state files, and a detected service name.
|
|
- Produces: `detect_ssh_unit`, `restore_ssh_dropin`, and a complete `harden_server_ssh USER HOME` transaction returning 0 only after validation and reload.
|
|
|
|
- [ ] **Step 1: Add failing success and rollback scenarios**
|
|
|
|
Extend the command state with `SSHD_RESULTS` and `RELOAD_RESULTS`, consumed one result per call. Add exact cases:
|
|
|
|
```text
|
|
success-without-prior-dropin: validate=0 reload=0
|
|
success-replaces-prior-dropin: validate=0 reload=0
|
|
candidate-invalid: validate=1,0 reload=<none>
|
|
candidate-reload-fails: validate=0,0 reload=1,0
|
|
rollback-validation-fails: validate=0,1 reload=1
|
|
rollback-reload-fails: validate=0,0 reload=1,1
|
|
```
|
|
|
|
Assert the desired two-line content, validation before reload, only the detected unit, byte-for-byte restoration, restored validation/reload ordering, nonzero status and no install handoff on every transactional failure, no `*.tmp`/`*.backup` residue on success, and retained backup plus recovery commands when rollback fails.
|
|
|
|
- [ ] **Step 2: Run the focused contract and confirm it fails on current code**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
tests/setup/root-server-bootstrap-contract
|
|
```
|
|
|
|
Expected: nonzero because current code writes the final path directly, never validates, guesses units through reload failure, and reports success after failed reload.
|
|
|
|
- [ ] **Step 3: Implement unit detection, atomic install, and rollback**
|
|
|
|
Use explicit transaction state and traps. The core shape is:
|
|
|
|
```bash
|
|
detect_ssh_unit() {
|
|
local unit
|
|
for unit in sshd.service ssh.service; do
|
|
systemctl cat "$unit" >/dev/null 2>&1 && { printf '%s\n' "$unit"; return 0; }
|
|
done
|
|
return 1
|
|
}
|
|
|
|
restore_ssh_dropin() {
|
|
if [[ -n "${ssh_backup:-}" && -e "$ssh_backup" ]]; then
|
|
local restore
|
|
restore="$(mktemp --tmpdir="$sshd_dir" .90-panama.XXXXXX.restore)" || return 1
|
|
cp -a -- "$ssh_backup" "$restore"
|
|
mv -f -- "$restore" "$ssh_dropin"
|
|
else
|
|
rm -f -- "$ssh_dropin"
|
|
fi
|
|
}
|
|
```
|
|
|
|
Create the candidate with `umask 077; mktemp --tmpdir="$sshd_dir" .90-panama.XXXXXX.tmp`, write exact desired content, and preserve an existing final file in a collision-safe same-directory `mktemp` name ending `.backup`, not `.conf`. Save any prior `EXIT`, `INT`, and `TERM` traps, then arm transaction traps before `mv -f` activates the candidate. The EXIT handler restores only while `ssh_transaction_active=1`; every success or handled failure path restores the prior traps before returning.
|
|
|
|
After activation:
|
|
|
|
```bash
|
|
if ! sshd -t; then
|
|
restore_ssh_dropin
|
|
sshd -t || rollback_failed=1
|
|
return 1
|
|
fi
|
|
if ! systemctl reload "$ssh_unit"; then
|
|
restore_ssh_dropin
|
|
sshd -t || rollback_failed=1
|
|
systemctl reload "$ssh_unit" || rollback_failed=1
|
|
return 1
|
|
fi
|
|
```
|
|
|
|
On clean success set `ssh_transaction_active=0`, clear the local traps, and remove the backup. On rollback failure keep the backup and print its absolute path plus `sshd -t` and `systemctl reload UNIT` recovery commands. Do not continue to clone/install after a hardening transaction returns nonzero.
|
|
|
|
- [ ] **Step 4: Verify failure status, cleanup, and old public behavior**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
bash -n boot tests/setup/root-server-bootstrap-contract
|
|
tests/setup/root-server-bootstrap-contract
|
|
tests/setup/boot-contract
|
|
tests/setup/role-contract
|
|
./bin/panama test --safe root-server-bootstrap
|
|
git diff --check
|
|
```
|
|
|
|
Expected: all pass; every rollback scenario preserves the previous drop-in and the safe success path validates before one reload.
|
|
|
|
- [ ] **Step 5: Commit the transaction**
|
|
|
|
```bash
|
|
git add boot tests/setup/root-server-bootstrap-contract
|
|
git commit -m "Fix: Roll back failed SSH hardening"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Synchronize operator documentation and close the SSH plan
|
|
|
|
**Files:**
|
|
- Modify: `README.md:40-48`
|
|
- Modify: `.claude/skills/panama/SKILL.md`
|
|
- Modify: `skills/panama-desktop/SKILL.md` only if it describes root bootstrap
|
|
- Test: `tests/setup/readme-contract`
|
|
|
|
**Interfaces:**
|
|
- Consumes: the landed `boot --server` behavior from Tasks 1-2.
|
|
- Produces: accurate user-facing preconditions, skip behavior, rollback behavior, and no claim of live-host proof.
|
|
|
|
- [ ] **Step 1: Write the documentation assertions first**
|
|
|
|
Extend `tests/setup/readme-contract` to require nearby root-bootstrap prose containing all of: verified target key, `sshd -t`, atomic drop-in, reload rollback, and hardening unavailable without a key. Reject wording that says Panama merely writes the file or that reload failure is ignored.
|
|
|
|
- [ ] **Step 2: Run the README contract and confirm the old prose fails**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
tests/setup/readme-contract
|
|
```
|
|
|
|
Expected: nonzero until README describes the transactional behavior.
|
|
|
|
- [ ] **Step 3: Update the documentation without claiming a live reload**
|
|
|
|
State plainly that Panama copies or verifies the target key, offers hardening only with exact safe ownership/modes, validates the complete config, reloads the detected unit, and restores the previous drop-in on failure. State that fixture contracts test these paths and no real daemon reload runs under `panama test --safe`.
|
|
|
|
- [ ] **Step 4: Run the plan gate**
|
|
|
|
```bash
|
|
bash -n boot tests/setup/root-server-bootstrap-contract
|
|
tests/setup/root-server-bootstrap-contract
|
|
tests/setup/boot-contract
|
|
tests/setup/role-contract
|
|
tests/setup/readme-contract
|
|
./bin/panama test --safe
|
|
git diff --check
|
|
```
|
|
|
|
Expected: 132 hermetic contracts pass after adding the new contract; non-hermetic skip counts remain unchanged.
|
|
|
|
- [ ] **Step 5: Commit the synchronized documentation**
|
|
|
|
```bash
|
|
git add README.md .claude/skills/panama/SKILL.md skills/panama-desktop/SKILL.md tests/setup/readme-contract
|
|
git commit -m "Docs: Explain transactional SSH hardening"
|
|
```
|