feat: add ssh-image/ — key-only Dropbear SSH injection for Spike 2 SD images

enable-ssh.sh turns a factory raw SD image (King Kong LE 0.97) into an
SSH-enabled one: injects the static dropbear/dropbearkey/sftp-server
binaries, the SysV init script (/etc/init.d/sshd), the S30sshd boot
symlink and the caller-supplied public key into the ext3 rootfs
partition with debugfs (no mounting). The input image is never
modified, the output path must be explicit and distinct, and every
byte outside p2 is verified identical afterwards. The public key is an
explicit -k argument — nothing is baked in and private keys are
rejected. --dry-run supported; --force for non-standard p2 geometry.

bin/ is byte-identical to the deployed king_kong_le-0_97_0.ssh image.
validate-image.sh functionally proves a result (boots the image's own
init script under qemu, real ssh pubkey login, password auth refused,
sftp round-trip); build-binaries.sh rebuilds bin/ in Docker.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-10-13 09:02:32 -04:00
co-authored by Claude Code
parent e8f812ba4a
commit cbdbe75f00
8 changed files with 812 additions and 0 deletions
+157
View File
@@ -0,0 +1,157 @@
# ssh-image — key-only Dropbear SSH for a Stern Spike 2 raw SD image
Turns a **factory** Spike 2 raw SD image (King Kong LE 0.97.0, 16 GiB) into an
SSH-enabled image, reproducing the working setup documented in
`/Users/matt/src/pinball-assets/README.md` sections 1 and 3. The original image
is only ever read; the result is a new image file you choose.
```
enable-ssh.sh -i <factory>.raw -o <factory>.ssh.raw -k <your>.pub
```
No password is ever set or needed — Dropbear is compiled without password
auth. The **public** key is an explicit input (`-k`); no private key is read,
stored, or embedded anywhere by this tool.
## What gets injected (into the ext3 rootfs partition, p2, only)
| Path on the machine | Source here | Mode | Owner |
|---|---|---|---|
| `/usr/local/bin/dropbear` | `bin/dropbear` | 0755 | 0:0 |
| `/usr/local/bin/dropbearkey` | `bin/dropbearkey` | 0755 | 0:0 |
| `/usr/libexec/sftp-server` | `bin/sftp-server` | 0755 | 0:0 |
| `/etc/init.d/sshd` | `files/sshd` | 0755 | 0:0 |
| `/etc/rc5.d/S30sshd` | symlink → `../init.d/sshd` | 0777 | 0:0 |
| `/home/root/.ssh` | new directory | 0700 | 0:0 |
| `/home/root/.ssh/authorized_keys` | your `-k` file | 0600 | 0:0 |
Byte-identical to the deployed `king_kong_le-0_97_0.ssh.16G.sdcard.raw`
(verified against it with `debugfs dump` + sha256 for all three binaries).
- **dropbear / dropbearkey** — 2025.88, static musl armhf. Password auth
compiled out (`DROPBEAR_SVR_PASSWORD_AUTH 0`), `DROPBEAR_REEXEC=0`
(the Spike 2 kernel is 3.14 and predates `execveat`).
- **sftp-server** — static Go binary (pkg/sftp). dropbear dropped its built-in
scp upstream; this makes `scp`/`rsync` work. It lives at
`/usr/libexec/sftp-server` because that is dropbear's compile-time
`SFTPSERVER_PATH` default — no `-s` flag in the init script is needed.
- **`/etc/init.d/sshd`** — SysV init script: on `start` it generates Dropbear
host keys (rsa/ecdsa/ed25519) into `/data/dropbear` if absent (host keys are
per-machine, so they are generated on first boot rather than baked in), then
runs dropbear on port 22.
- **`/etc/rc5.d/S30sshd`** — boot hook; S30 runs before the game's `S95game`.
Everything else in the image — p1 (boot, FAT), the game partition, and every
data/dump byte — is untouched and **verified byte-identical** after the run
(`cmp` over both regions outside p2).
## Requirements
- macOS (Homebrew). The image work uses `cp`, `dd`, `cmp`, `shasum` — all stock.
- `brew install e2fsprogs` — provides `debugfs` and `e2fsck` (1.4x).
- `python3` (system) — for MBR parsing and path resolution.
- For `validate-image.sh` only: Docker (any linux container) — it runs
privileged (bind mounts for /dev, /proc) and uses `qemu-user-static` to
boot the ARM rootfs. `build-binaries.sh` also needs Docker plus network
access (musl.cc, GitHub, codeload).
## Usage
```sh
# 1. make sure you have a public key (the private half never enters this repo)
ssh-keygen -t ed25519 -a 100 -f ~/.ssh/spike2 # if you don't have one
# use ~/.ssh/spike2.pub as the -k input
# 2. dry run: parses the MBR, inspects p2, prints the exact plan, writes nothing
./enable-ssh.sh -i ~/images/king_kong_le-0_97_0.Release.16G.sdcard.raw \
-o /tmp/king_kong_le-0_97_0.ssh.16G.sdcard.raw \
-k ~/.ssh/spike2.pub --dry-run
# 3. real run (APFS clone is instant; a full 16 GiB copy on other volumes takes a while)
./enable-ssh.sh -i ~/images/king_kong_le-0_97_0.Release.16G.sdcard.raw \
-o ~/images/king_kong_le-0_97_0.ssh.16G.sdcard.raw \
-k ~/.ssh/spike2.pub
# 4. optional deep check before flashing (Docker, qemu-arm, real ssh login)
./validate-image.sh -i ~/images/king_kong_le-0_97_0.ssh.16G.sdcard.raw \
-k ~/.ssh/spike2.pub
```
Options: `-b DIR` / `-f DIR` point at alternative binary / init-script
directories (defaults are `./bin` and `./files` beside the script);
`--force` accepts a p2 geometry other than the known King Kong layout
(12 MiB offset, 336 MiB size — the script parses the MBR, it does not assume);
`--overwrite` replaces an existing output file (refused by default);
`--dry-run` writes nothing.
The script refuses to run if: input/output are the same file, the output
already exists, the key file contains a private key or unrecognised lines,
the image has no MBR / no ext3 rootfs partition, or the rootfs fails a
read-only `e2fsck -fn` before **or** after injection.
## Safety model
- The input image is opened read-only and is never written; its size is
re-checked at the end.
- The output is created fresh (`cp -c` APFS clone when possible, else full
copy) and must be a different path from the input.
- Only one region is ever written back: p2, exactly `skip`/`count` in 512-byte
sectors. Afterwards `cmp` proves every byte before and after p2 equals the
input — including wherever the data/dump partitions live.
- Host keys are generated on the machine at first boot (`/data/dropbear`),
never baked into the image.
- **Before flashing over a card that is in use: back up its data partition**
(scores/settings/NVM). The image's `/data` is factory-empty; flashing it
over a live card erases that data. (The original setup lost it once —
restored from a pre-SSH backup via tar-over-ssh.)
- Flash to a **full-size** SD card in the machine's `mmcblk0` slot. Never
flash to the machine's live SD while it is the boot device.
## Validation
`enable-ssh.sh` self-verifies every run (filesystem health before/after,
presence + mode + owner of all injected objects, sha256 of the three
binaries inside the result, and the `cmp` preservation proof).
`validate-image.sh` goes further: it extracts the output image's p2 into a
scratch rootfs tree (debugfs `rdump`, no loop device needed) in a privileged
Docker container, starts dropbear **via the image's own
`/etc/init.d/sshd`** (first-boot path: the image's `dropbearkey` generates
the host keys into `/data/dropbear`), and then from the container
(a) logs in as root over ssh with a throwaway ed25519 key generated inside
the container (your private key is never read or stored), (b) confirms
password auth is refused, and (c) runs `sftp` put/get to prove the
sftp-server wiring. It also statically checks that the deployed
`authorized_keys` in the image equals the `-k` file you gave
`enable-ssh.sh`. The image file is only ever read; all test mutations happen
on a scratch copy of p2.
## Building the binaries (provenance and rebuild)
`bin/dropbear`, `bin/dropbearkey` and `bin/sftp-server` were built once, on
2026-10-11, in the `pinball-assets/.ssh-build/` tree:
| binary | build | notes |
|---|---|---|
| `dropbear`, `dropbearkey` | `dropbear 2025.88`, `./configure --host=arm-linux-musleabihf --disable-zlib --enable-static`, `localoptions.h`: `DROPBEAR_SVR_PASSWORD_AUTH 0`, `DROPBEAR_CLI_PASSWORD_AUTH 0`, `DROPBEAR_SVR_PUBKEY_AUTH 1`, `DROPBEAR_REEXEC 0` | musl.cc `arm-linux-musleabihf` cross toolchain; Debian 12 container |
| `sftp-server` | Go 1.19+ in GOPATH mode from codeload tarballs (`pkg/sftp v1.13.2`, `kr/fs v0.1.0`, `golang.org/x/crypto v0.7.0`, `x/sys v0.7.0`), `GOARCH=arm GOARM=7 CGO_ENABLED=0`, `go build -trimpath -ldflags="-s -w"` | static, stripped |
To rebuild them from this directory (requires Docker):
```sh
./build-binaries.sh # rewrites bin/ and prints file(1) + sha256 for each
```
The originals live on at `pinball-assets/.ssh-build/out-musl/{dropbear,dropbearkey}`
and `pinball-assets/.ssh-build/out-sftp/sftp-server` with the build scripts
`build.sh` (dropbear) and `build-sftp-go.sh` (sftp).
## Files
| Path | What |
|---|---|
| `enable-ssh.sh` | the injection tool (input image + public key → output image) |
| `validate-image.sh` | Docker/qemu functional test of an output image (optional) |
| `build-binaries.sh` | rebuilds `bin/` in a Docker container (optional) |
| `files/sshd` | the SysV init script injected to `/etc/init.d/sshd` |
| `bin/dropbear`, `bin/dropbearkey`, `bin/sftp-server` | the static armhf binaries injected into the image |