diff --git a/ssh-image/README.md b/ssh-image/README.md new file mode 100644 index 0000000..d2195e4 --- /dev/null +++ b/ssh-image/README.md @@ -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 .raw -o .ssh.raw -k .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 | diff --git a/ssh-image/bin/dropbear b/ssh-image/bin/dropbear new file mode 100755 index 0000000..c64d333 Binary files /dev/null and b/ssh-image/bin/dropbear differ diff --git a/ssh-image/bin/dropbearkey b/ssh-image/bin/dropbearkey new file mode 100755 index 0000000..89cd496 Binary files /dev/null and b/ssh-image/bin/dropbearkey differ diff --git a/ssh-image/bin/sftp-server b/ssh-image/bin/sftp-server new file mode 100755 index 0000000..5185908 Binary files /dev/null and b/ssh-image/bin/sftp-server differ diff --git a/ssh-image/build-binaries.sh b/ssh-image/build-binaries.sh new file mode 100755 index 0000000..9b55179 --- /dev/null +++ b/ssh-image/build-binaries.sh @@ -0,0 +1,122 @@ +#!/bin/sh +# build-binaries.sh — rebuild bin/{dropbear,dropbearkey,sftp-server} in a +# Docker container, reproducing the original pinball-assets/.ssh-build builds: +# +# dropbear/dropbearkey : dropbear 2025.88, static musl armhf +# (password auth compiled out, DROPBEAR_REEXEC=0 — +# the Spike 2 kernel 3.14 predates execveat) +# sftp-server : static Go (pkg/sftp), GOARCH=arm GOARM=7, +# built in GOPATH mode from codeload tarballs +# (proxy.golang.org is unreachable on the dev network) +# +# Overwrites bin/ in place and prints file(1) + sha256 for each result. +# Requires docker. The container downloads: +# https://musl.cc/arm-linux-musleabihf-cross.tgz +# https://github.com/mkj/dropbear (tag DROPBEAR_2025.88) +# https://codeload.github.com/pkg/sftp (v1.13.2) + deps +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +DOCKER_IMAGE="debian:12" + +command -v docker >/dev/null 2>&1 || { echo "error: docker not found" >&2; exit 1; } + +# scratch dir under $HOME (Docker Desktop on macOS mishandles /tmp bind mounts) +WORK=$HOME/.ssh-image-build.$$ +mkdir -p "$WORK" +trap 'rm -rf "$WORK"' EXIT + +cat > "$WORK/build.sh" <<'BUILD' +#!/bin/sh +set -e +apt-get update -qq +apt-get install -y -qq make wget xz-utils file golang-go >/dev/null +cd /src + +# --- dropbear 2025.88, static musl armhf ------------------------------------ +if [ ! -d musl-toolchain ]; then + wget -q https://musl.cc/arm-linux-musleabihf-cross.tgz -O musl.tgz + mkdir musl-toolchain && tar xzf musl.tgz -C musl-toolchain --strip-components=1 +fi +export PATH=/src/musl-toolchain/bin:$PATH +if [ ! -d dropbear ]; then + wget -q https://github.com/mkj/dropbear/archive/refs/tags/DROPBEAR_2025.88.tar.gz -O dropbear.tar.gz + tar xzf dropbear.tar.gz && mv dropbear-DROPBEAR_2025.88 dropbear +fi +cd dropbear +cat > localoptions.h <<'OPTS' +#define DROPBEAR_SVR_PASSWORD_AUTH 0 +#define DROPBEAR_CLI_PASSWORD_AUTH 0 +#define DROPBEAR_SVR_PUBKEY_AUTH 1 +#define DROPBEAR_REEXEC 0 +OPTS +CC=arm-linux-musleabihf-gcc \ + ./configure --host=arm-linux-musleabihf --disable-zlib --enable-static >/dev/null 2>&1 +make clean >/dev/null 2>&1 || true +make -j"$(nproc)" PROGRAMS="dropbear dropbearkey" >/dev/null 2>&1 +file dropbear dropbearkey +cp dropbear dropbearkey /out/ + +# --- sftp-server: static Go, armv7, GOPATH mode from codeload tarballs ------- +export GOARCH=arm GOARM=7 GOOS=linux CGO_ENABLED=0 GO111MODULE=off +export GOPATH=/src/gopath +mkdir -p $GOPATH/src/github.com/pkg $GOPATH/src/github.com/kr $GOPATH/src/golang.org/x +cd $GOPATH/src/github.com/pkg +[ -d sftp ] || (wget -q https://codeload.github.com/pkg/sftp/tar.gz/refs/tags/v1.13.2 -O sftp.tgz && mkdir sftp && tar xzf sftp.tgz -C sftp --strip-components=1) +cd $GOPATH/src/github.com/kr +[ -d fs ] || (wget -q https://codeload.github.com/kr/fs/tar.gz/refs/tags/v0.1.0 -O fs.tgz && mkdir fs && tar xzf fs.tgz -C fs --strip-components=1) +cd $GOPATH/src/golang.org/x +[ -d crypto ] || (wget -q https://codeload.github.com/golang/crypto/tar.gz/refs/tags/v0.7.0 -O crypto.tgz && mkdir crypto && tar xzf crypto.tgz -C crypto --strip-components=1) +[ -d sys ] || (wget -q https://codeload.github.com/golang/sys/tar.gz/refs/tags/v0.7.0 -O sys.tgz && mkdir sys && tar xzf sys.tgz -C sys --strip-components=1) +mkdir -p /src/sftp-go && cd /src/sftp-go +cat > main.go <<'GO' +package main + +import ( + "io" + "log" + "os" + + "github.com/pkg/sftp" +) + +type rw struct{} + +func (rw) Read(p []byte) (int, error) { return os.Stdin.Read(p) } +func (rw) Write(p []byte) (int, error) { return os.Stdout.Write(p) } +func (rw) Close() error { return nil } + +func main() { + srv, err := sftp.NewServer(rw{}) + if err != nil { + log.Fatal(err) + } + if err := srv.Serve(); err != nil && err != io.EOF { + log.Fatal(err) + } +} +GO +go build -trimpath -ldflags="-s -w" -o /out/sftp-server . +file /out/sftp-server +BUILD +chmod +x "$WORK/build.sh" +mkdir -p "$WORK/out" + +echo "[build] running container ($DOCKER_IMAGE) — downloads ~200 MB, takes a few minutes" +if ! docker run --rm -v "$WORK:/src:rw" "$DOCKER_IMAGE" /bin/sh /src/build.sh; then + echo "error: build failed" >&2 + exit 1 +fi + +for B in dropbear dropbearkey sftp-server; do + [ -f "$WORK/out/$B" ] || { echo "error: build did not produce $B" >&2; exit 1; } + cp "$WORK/out/$B" "$SCRIPT_DIR/bin/$B" + chmod 755 "$SCRIPT_DIR/bin/$B" +done +echo "[build] done. bin/ now contains:" +for B in dropbear dropbearkey sftp-server; do + echo " $B: $(file -b "$SCRIPT_DIR/bin/$B" | cut -c1-60)..." + shasum -a 256 "$SCRIPT_DIR/bin/$B" | sed 's/^/ sha256 /' +done +echo "[build] NOTE: rebuilt binaries may differ from the originally deployed ones" +echo "[build] (toolchain versions); if in doubt, keep the shipped bin/ files." diff --git a/ssh-image/enable-ssh.sh b/ssh-image/enable-ssh.sh new file mode 100755 index 0000000..bf9f113 --- /dev/null +++ b/ssh-image/enable-ssh.sh @@ -0,0 +1,337 @@ +#!/bin/sh +# enable-ssh.sh — inject key-only Dropbear SSH into a Stern Spike 2 raw SD image. +# +# Reproduces the working setup documented in pinball-assets/README.md (sections 1 +# and 3): the ext3 rootfs partition (p2) of a factory Spike 2 image (King Kong +# LE 0.97.0, 16 GiB) is replaced with a copy that additionally contains +# /usr/local/bin/dropbear static musl armhf, 2025.88 — password auth +# compiled out, DROPBEAR_REEXEC=0 (kernel 3.14 +# predates execveat) +# /usr/local/bin/dropbearkey static musl armhf +# /usr/libexec/sftp-server static Go sftp server — dropbear's compile-time +# SFTPSERVER_PATH default, so scp/rsync work +# without a -s flag +# /etc/init.d/sshd SysV init: generates host keys into +# /data/dropbear at first boot, dropbear on 22 +# /etc/rc5.d/S30sshd boot symlink -> ../init.d/sshd (before S95game) +# /home/root/.ssh/authorized_keys the public key(s) you supply (dir 0700, key 0600) +# Everything else in the image — p1 boot, the game partition, and all data/dump +# bytes — is left bit-for-bit untouched and verified to be so afterwards. +# No private keys are ever read, stored, or embedded. +# +# Usage: +# enable-ssh.sh -i INPUT_IMAGE -o OUTPUT_IMAGE -k PUBLIC_KEY [options] +# +# -i FILE input raw SD image (factory/Release image). NEVER modified. +# -o FILE output raw SD image to create. Must differ from the input. +# -k FILE authorized_keys content: one or more public key lines +# (ssh-ed25519, ssh-rsa, ecdsa-sha2-*, sk-*). No private keys. +# -b DIR dir with dropbear/dropbearkey/sftp-server (default: ./bin) +# -f DIR dir with the sshd init script (default: ./files) +# --dry-run parse and print the plan; write nothing +# --force allow a p2 geometry that differs from the known +# King Kong LE 0.97 layout (12 MiB offset, 336 MiB size) +# --overwrite allow an existing output file to be replaced +# -h help +# +# Exit status: 0 on success (or a complete dry run), non-zero on any error. +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +BINDIR="$SCRIPT_DIR/bin" +FILEDIR="$SCRIPT_DIR/files" + +INPUT= OUTPUT= KEYFILE= DRYRUN=0 FORCE=0 OVERWRITE=0 + +usage() { + cat <<'USAGE' +Usage: enable-ssh.sh -i INPUT_IMAGE -o OUTPUT_IMAGE -k PUBLIC_KEY [options] + -i FILE input raw SD image (factory/Release image). NEVER modified. + -o FILE output raw SD image to create. Must differ from the input. + -k FILE authorized_keys content: one or more public key lines + (ssh-ed25519, ssh-rsa, ecdsa-sha2-*, sk-*). No private keys. + -b DIR dir with dropbear/dropbearkey/sftp-server (default: ./bin) + -f DIR dir with the sshd init script (default: ./files) + --dry-run parse and print the plan; write nothing + --force allow a p2 geometry different from the known layout + --overwrite allow an existing output file to be replaced + -h help +USAGE +} + +while [ $# -gt 0 ]; do + case "$1" in + -i) INPUT=$2; shift 2 ;; + -o) OUTPUT=$2; shift 2 ;; + -k) KEYFILE=$2; shift 2 ;; + -b) BINDIR=$2; shift 2 ;; + -f) FILEDIR=$2; shift 2 ;; + --dry-run) DRYRUN=1; shift ;; + --force) FORCE=1; shift ;; + --overwrite) OVERWRITE=1; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "error: unknown argument: $1" >&2; usage >&2; exit 2 ;; + esac +done + +err() { echo "error: $*" >&2; exit 1; } +say() { echo "[enable-ssh] $*"; } +sha256() { + if command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | cut -d' ' -f1 + else sha256sum "$1" | cut -d' ' -f1; fi +} +realpath() { python3 -c 'import os,sys; print(os.path.realpath(sys.argv[1]))' "$1"; } + +[ -n "$INPUT" ] || { usage >&2; err "missing -i INPUT_IMAGE"; } +[ -n "$OUTPUT" ] || { usage >&2; err "missing -o OUTPUT_IMAGE"; } +[ -n "$KEYFILE" ] || { usage >&2; err "missing -k PUBLIC_KEY"; } + +command -v python3 >/dev/null 2>&1 || err "python3 not found (needed for MBR parsing)" +command -v debugfs >/dev/null 2>&1 || err "debugfs not found (brew install e2fsprogs)" +command -v e2fsck >/dev/null 2>&1 || err "e2fsck not found (brew install e2fsprogs)" + +# --- inputs ----------------------------------------------------------------- +[ -f "$INPUT" ] && [ -r "$INPUT" ] || err "input image not a readable regular file: $INPUT" +IN_REAL=$(realpath "$INPUT") +OUT_REAL=$(realpath "$OUTPUT") +[ "$IN_REAL" != "$OUT_REAL" ] || err "output image must differ from input image ($OUT_REAL)" +if [ -e "$OUTPUT" ]; then + [ "$OVERWRITE" = 1 ] || err "output exists: $OUTPUT (refusing to overwrite; use --overwrite)" +fi + +for B in dropbear dropbearkey sftp-server; do + [ -f "$BINDIR/$B" ] || err "missing binary: $BINDIR/$B (see README, 'Building the binaries')" +done +[ -f "$FILEDIR/sshd" ] || err "missing init script: $FILEDIR/sshd" + +# public key: one or more public-key lines, nothing that smells private +[ -f "$KEYFILE" ] && [ -r "$KEYFILE" ] || err "public key file not readable: $KEYFILE" +KEYS=$(grep -c '[^[:space:]]' "$KEYFILE" 2>/dev/null) || KEYS=0 +[ "$KEYS" -ge 1 ] || err "public key file has no lines: $KEYFILE" +grep -n 'PRIVATE' "$KEYFILE" >/dev/null 2>&1 && \ + err "key file contains 'PRIVATE' — refusing (private keys are not allowed here)" +if [ "$(tr -d '\r' < "$KEYFILE" | wc -c | tr -d '[:space:]')" != "$(wc -c < "$KEYFILE" | tr -d '[:space:]')" ]; then + err "key file has CRLF line endings — convert with: tr -d '\\r' < in > out" +fi +while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + '') continue ;; + ssh-ed25519*|ssh-rsa*|ecdsa-sha2-nistp256*|ecdsa-sha2-nistp384*|ecdsa-sha2-nistp521*|sk-ssh-ed25519*|sk-ecdsa-sha2-nistp256*) + ;; + *) err "not a recognised public key line: $line" ;; + esac +done < "$KEYFILE" + +# --- partition table (read-only) --------------------------------------------- +# Known King Kong LE 0.97 layout: p1 FAT32 @4MiB, p2 ext3 rootfs @12MiB, +# 336 MiB (688128 sectors), then the game/data region. We parse the MBR +# rather than assume it. +P2_INFO=$(python3 - "$IN_REAL" <<'PYEOF' +import struct, sys +path = sys.argv[1] +with open(path, "rb") as f: + mbr = f.read(512) +if mbr[510:512] != b"\x55\xaa": + sys.exit("no MBR boot signature (55aa) — not a raw MBR image?") +for i in range(4): + e = mbr[446 + 16*i : 446 + 16*(i+1)] + if not e or e == b"\x00" * 16: + continue + if e[4] == 0x83: # primary Linux partition + lba, size = struct.unpack("/dev/null + if debugfs -R 'stat /usr/local/bin/dropbear' "$WORK/p2" 2>/dev/null | grep -q '^Inode:'; then + say "input p2 already contains /usr/local/bin/dropbear (image may already be SSH-enabled)" + else + say "input p2 does not contain dropbear (factory state)" + fi + say "plan:" + say " 1. copy image: $IN_REAL -> $OUT_REAL (APFS clone if possible, else full copy)" + say " 2. extract p2 from the copy, inject with debugfs (no mounting):" + say " /usr/local/bin/dropbear <- $BINDIR/dropbear (0755, 0:0)" + say " /usr/local/bin/dropbearkey <- $BINDIR/dropbearkey (0755, 0:0)" + say " /usr/libexec/sftp-server <- $BINDIR/sftp-server (0755, 0:0)" + say " /etc/init.d/sshd <- $FILEDIR/sshd (0755, 0:0)" + say " /etc/rc5.d/S30sshd -> symlink ../init.d/sshd" + say " /home/root/.ssh (new dir, 0700, 0:0)" + say " /home/root/.ssh/authorized_keys <- $KEYFILE (0600, 0:0)" + say " 3. e2fsck -fn the injected p2 (abort if unhealthy)" + say " 4. write p2 back at offset $P2_OFFSET" + say " 5. verify: p2 region differs from input; every other byte identical (cmp)" + exit 0 +fi + +# --- real run ----------------------------------------------------------------- +WORK=$(mktemp -d "${TMPDIR:-/tmp}/ssh-image.XXXXXX") +trap 'rm -rf "$WORK"' EXIT +say "work dir: $WORK" + +say "copying input image -> output (APFS clone if possible, else full copy) ..." +if ! cp -c "$IN_REAL" "$OUT_REAL" 2>/dev/null; then + say "clone not available; doing a full copy (16 GiB — this takes a while)" + cp "$IN_REAL" "$OUT_REAL" +fi +OUT_SIZE=$(wc -c < "$OUT_REAL" | tr -d '[:space:]') +[ "$OUT_SIZE" = "$IN_SIZE" ] || err "output size after copy mismatch: $OUT_SIZE != $IN_SIZE" + +say "extracting p2 from the copy ..." +dd if="$OUT_REAL" of="$WORK/p2" bs=512 skip="$P2_SECTORS" count="$P2_SIZE_SECTORS" 2>/dev/null +[ "$(wc -c < "$WORK/p2" | tr -d '[:space:]')" = "$P2_SIZE" ] || err "p2 extraction size mismatch" + +say "baseline filesystem check (e2fsck -fn) ..." +e2fsck -fn "$WORK/p2" >/dev/null 2>&1 || err "baseline e2fsck failed on p2 — input rootfs is not healthy; aborting (output left as an unmodified copy)" + +# --- inject with debugfs (no mounting) ---------------------------------------- +# debugfs `write` forces ownership 0:0 and copies the source file's mode; modes +# are pinned explicitly afterwards with `sif` (decimal 16-bit mode, type bits +# included: file 0755=33261, file 0600=33152, dir 0755=16877, dir 0700=16832). +# `symlink ` requires the source to exist in the fs and resolves +# it against debugfs's cwd, so we `cd` into /etc/rc5.d first; the link content +# written is the literal source string. +say "injecting files with debugfs ..." +exists() { debugfs -R "stat $1" "$WORK/p2" 2>/dev/null | grep -q '^Inode:'; } + +CMDS="$WORK/cmds" +: > "$CMDS" +exists /usr/libexec || echo "mkdir /usr/libexec" >> "$CMDS" +exists /home/root/.ssh || echo "mkdir /home/root/.ssh" >> "$CMDS" +exists /etc/rc5.d/S30sshd && echo "rm /etc/rc5.d/S30sshd" >> "$CMDS" + +echo "write $BINDIR/dropbear /usr/local/bin/dropbear" >> "$CMDS" +echo "write $BINDIR/dropbearkey /usr/local/bin/dropbearkey" >> "$CMDS" +echo "write $BINDIR/sftp-server /usr/libexec/sftp-server" >> "$CMDS" +echo "write $FILEDIR/sshd /etc/init.d/sshd" >> "$CMDS" +echo "write $KEYFILE /home/root/.ssh/authorized_keys" >> "$CMDS" +echo "cd /etc/rc5.d" >> "$CMDS" +echo "symlink S30sshd ../init.d/sshd" >> "$CMDS" + +DBG1="$WORK/dbg1.log" +debugfs -w -f "$CMDS" "$WORK/p2" > "$DBG1" 2>&1 || err "debugfs injection failed" +if grep -qiE 'error|failed|denied|not found' "$DBG1"; then + sed 's/^/ /' "$DBG1" >&2 + err "debugfs reported errors (log above)" +fi + +# pin the exact modes (session 2: non-interactive sif) +SMODES="$WORK/smodes" +: > "$SMODES" +echo "sif /usr/local/bin/dropbear mode 33261" >> "$SMODES" # 0100755 +echo "sif /usr/local/bin/dropbearkey mode 33261" >> "$SMODES" # 0100755 +echo "sif /usr/libexec/sftp-server mode 33261" >> "$SMODES" # 0100755 +echo "sif /etc/init.d/sshd mode 33261" >> "$SMODES" # 0100755 +echo "sif /usr/libexec mode 16877" >> "$SMODES" # 0040755 +echo "sif /home/root/.ssh mode 16832" >> "$SMODES" # 0040700 +echo "sif /home/root/.ssh/authorized_keys mode 33152" >> "$SMODES" # 0100600 +DBG2="$WORK/dbg2.log" +debugfs -w -f "$SMODES" "$WORK/p2" > "$DBG2" 2>&1 || err "debugfs mode-setting failed" +if grep -qiE 'error|failed|denied|not found' "$DBG2"; then + sed 's/^/ /' "$DBG2" >&2 + err "debugfs reported errors (log above)" +fi + +say "post-injection filesystem check (e2fsck -fn) ..." +e2fsck -fn "$WORK/p2" >/dev/null 2>&1 || err "post-injection e2fsck failed — p2 not written back; output image is an unmodified copy" + +# sanity: the injected objects must be in place with the right modes and 0:0 +check_obj() { + debugfs -R "stat $1" "$WORK/p2" 2>/dev/null | grep -q "Mode: *$2" || err "injected $1 missing or wrong mode (want $2)" + debugfs -R "stat $1" "$WORK/p2" 2>/dev/null | grep -q 'User: 0 Group: 0' || err "injected $1 not owned by 0:0" +} +check_obj /usr/local/bin/dropbear 0755 +check_obj /usr/local/bin/dropbearkey 0755 +check_obj /usr/libexec/sftp-server 0755 +check_obj /etc/init.d/sshd 0755 +check_obj /home/root/.ssh 0700 +check_obj /home/root/.ssh/authorized_keys 0600 +debugfs -R 'stat /etc/rc5.d/S30sshd' "$WORK/p2" 2>/dev/null | grep -q 'Fast link dest: "../init.d/sshd"' \ + || err "boot symlink /etc/rc5.d/S30sshd missing or wrong target" + +# binaries in the p2 must match the ones we injected +for B in dropbear dropbearkey sftp-server; do + case "$B" in + dropbear) P=/usr/local/bin/dropbear ;; + dropbearkey) P=/usr/local/bin/dropbearkey ;; + sftp-server) P=/usr/libexec/sftp-server ;; + esac + debugfs -R "dump $P $WORK/check-$B" "$WORK/p2" 2>/dev/null + [ "$(sha256 "$WORK/check-$B")" = "$(sha256 "$BINDIR/$B")" ] || err "injected $B does not match $BINDIR/$B" + rm -f "$WORK/check-$B" +done + +say "writing p2 back at offset $P2_OFFSET ..." +dd if="$WORK/p2" of="$OUT_REAL" bs=512 seek="$P2_SECTORS" conv=notrunc 2>/dev/null + +# --- verification --------------------------------------------------------------- +say "verifying ..." +OUT_SIZE=$(wc -c < "$OUT_REAL" | tr -d '[:space:]') +[ "$OUT_SIZE" = "$IN_SIZE" ] || err "output size changed: $OUT_SIZE != $IN_SIZE" + +# every byte before and after p2 must be identical to the input +say "comparing bytes before p2 (0 .. $P2_OFFSET) ..." +cmp -n "$P2_OFFSET" "$IN_REAL" "$OUT_REAL" || err "bytes before p2 differ — output discarded" +say "comparing bytes after p2 ($((P2_OFFSET + P2_SIZE)) .. end) ..." +cmp -i "$((P2_OFFSET + P2_SIZE))" "$IN_REAL" "$OUT_REAL" || err "bytes after p2 differ (game/data partitions!) — output discarded" + +# input must be untouched +IN_SIZE2=$(wc -c < "$IN_REAL" | tr -d '[:space:]') +[ "$IN_SIZE2" = "$IN_SIZE" ] || err "input image size changed — this must not happen" + +say "done." +say "output image : $OUT_REAL" +say " sha256 : $(sha256 "$OUT_REAL")" +say "" +say "next steps:" +say " - flash to a full-size SD card and insert into the machine's mmcblk0 slot" +say " - WARNING: the image's /data (scores/settings) is factory-empty; flashing over" +say " a card in use erases it. Back up the live card's data first (see README)." +say " - on first boot the machine generates Dropbear host keys into /data/dropbear" +say " - then: ssh -i root@ (key-only, port 22)" +say " - optional deeper check before flashing:" +say " ./validate-image.sh -i $OUT_REAL -k $KEYFILE" diff --git a/ssh-image/files/sshd b/ssh-image/files/sshd new file mode 100755 index 0000000..0ab82d6 --- /dev/null +++ b/ssh-image/files/sshd @@ -0,0 +1,36 @@ +#!/bin/sh + +DAEMON=/usr/local/bin/dropbear +KEYDIR=/data/dropbear + +NAME=dropbear +case "$1" in + start) + echo -n "Starting $NAME: " + mkdir -p $KEYDIR + for T in rsa ecdsa ed25519; do + if [ ! -f $KEYDIR/dropbear_${T}_host_key ]; then + /usr/local/bin/dropbearkey -t $T -f $KEYDIR/dropbear_${T}_host_key + fi + done + $DAEMON \ + -r $KEYDIR/dropbear_rsa_host_key \ + -r $KEYDIR/dropbear_ecdsa_host_key \ + -r $KEYDIR/dropbear_ed25519_host_key + echo "OK" + ;; + stop) + echo -n "Stopping $NAME: " + start-stop-daemon -K -n $NAME + echo "OK" + ;; + restart) + $0 stop + $0 start + ;; + *) + echo "Usage: $0 {start|stop|restart}" + exit 1 +esac + +exit 0 diff --git a/ssh-image/validate-image.sh b/ssh-image/validate-image.sh new file mode 100755 index 0000000..33d49e4 --- /dev/null +++ b/ssh-image/validate-image.sh @@ -0,0 +1,160 @@ +#!/bin/sh +# validate-image.sh — functional test of an image produced by enable-ssh.sh. +# +# Extracts the output image's rootfs (p2) into a scratch tree (debugfs +# rdump — no loop device needed) inside a privileged Docker container, +# starts dropbear via the image's OWN /etc/init.d/sshd (which generates +# host keys into /data/dropbear exactly as on first boot), then: +# 1. checks the deployed authorized_keys equals the -k public key you gave +# enable-ssh.sh (static check, outside the container) +# 2. ssh-login as root with a throwaway ed25519 key generated INSIDE the +# container (no private key of yours is ever read or stored) +# 3. confirms password auth is refused +# 4. runs sftp put/get to prove the sftp-server wiring +# +# The image file itself is only read; all test mutations happen on a scratch +# copy of p2 inside the container. +# +# Usage: validate-image.sh -i OUTPUT_IMAGE -k PUBLIC_KEY [--image docker-image] +# +# Requires: docker (linux containers), a volume with ~1 GiB free for the p2 +# scratch copy. Takes a few minutes (container setup + key generation). +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +INPUT= KEYFILE= DOCKER_IMAGE="debian:12" + +while [ $# -gt 0 ]; do + case "$1" in + -i) INPUT=$2; shift 2 ;; + -k) KEYFILE=$2; shift 2 ;; + --image) DOCKER_IMAGE=$2; shift 2 ;; + -h|--help) sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "error: unknown argument: $1" >&2; exit 2 ;; + esac +done + +err() { echo "error: $*" >&2; exit 1; } +say() { echo "[validate] $*"; } + +[ -n "$INPUT" ] && [ -n "$KEYFILE" ] || { err "usage: validate-image.sh -i OUTPUT_IMAGE -k PUBLIC_KEY"; } +[ -f "$INPUT" ] || err "image not found: $INPUT" +[ -f "$KEYFILE" ] || err "public key not found: $KEYFILE" +command -v docker >/dev/null 2>&1 || err "docker not found" +docker info >/dev/null 2>&1 || err "docker daemon not reachable" + +# --- parse the MBR (read-only) for p2 ---------------------------------------- +P2_INFO=$(python3 - "$INPUT" <<'PYEOF' +import struct, sys +with open(sys.argv[1], "rb") as f: + mbr = f.read(512) +assert mbr[510:512] == b"\x55\xaa", "no MBR boot signature" +for i in range(4): + e = mbr[446 + 16*i : 446 + 16*(i+1)] + if e and e != b"\x00" * 16 and e[4] == 0x83: + lba, size = struct.unpack(" $WORK/p2.img" +dd if="$INPUT" of="$WORK/p2.img" bs=512 skip="$P2_SECTORS" count="$P2_SIZE_SECTORS" 2>/dev/null +[ "$(wc -c < "$WORK/p2.img" | tr -d '[:space:]')" = "$P2_SIZE" ] || err "p2 extraction size mismatch" + +# --- static check: deployed authorized_keys must equal the -k input ----------- +say "static check: authorized_keys in the image vs -k file" +debugfs -R "dump /home/root/.ssh/authorized_keys $WORK/deployed-ak" "$WORK/p2.img" 2>/dev/null \ + || err "image has no /home/root/.ssh/authorized_keys (was it built with enable-ssh.sh?)" +# normalise: ignore trailing-newline differences +tr -d '\n' < "$WORK/deployed-ak" > "$WORK/deployed-ak.n" +tr -d '\n' < "$KEYFILE" > "$WORK/keyfile.n" +if ! cmp -s "$WORK/deployed-ak.n" "$WORK/keyfile.n"; then + err "authorized_keys in the image does not match -k ($KEYFILE)" +fi +say " authorized_keys matches" + +# --- container test ------------------------------------------------------------ +say "running container test ($DOCKER_IMAGE, ~a few minutes) ..." +cat > "$WORK/container-test.sh" <<'TEST' +#!/bin/sh +set -e +apt-get update -qq >/dev/null +apt-get install -y -qq openssh-client qemu-user-static e2fsprogs >/dev/null 2>&1 +# extract the rootfs to a scratch tree with debugfs (no loop device needed — +# Docker Desktop for Mac does not provide one; rdump preserves symlinks and modes) +mkdir -p /mnt2 +debugfs -R 'rdump / /mnt2' /work/p2.img 2>/dev/null +echo "--- rootfs extracted (scratch copy); sanity ---" +ls -la /mnt2/bin/sh /mnt2/etc/init.d/sshd /mnt2/usr/local/bin/dropbear +# chrooted ARM binaries need an explicit qemu prefix (no binfmt in the container) +cp /usr/bin/qemu-arm-static /mnt2/usr/bin/ +mount --bind /dev /mnt2/dev +mount -t proc proc /mnt2/proc +# start dropbear exactly the way the machine will, via the image's own init +# script (first-boot path: generates host keys into /data/dropbear, then runs +# the daemon on port 22) +echo "--- /etc/init.d/sshd start ---" +timeout 300 chroot /mnt2 /usr/bin/qemu-arm-static /bin/sh /etc/init.d/sshd start +sleep 3 +ls -la /mnt2/data/dropbear/ +echo "--- pubkey login test (throwaway key, user root) ---" +ssh-keygen -t ed25519 -N '' -f /tmp/vkey -q +mkdir -p /mnt2/home/root/.ssh +cp /tmp/vkey.pub /mnt2/home/root/.ssh/authorized_keys +chmod 700 /mnt2/home/root/.ssh; chmod 600 /mnt2/home/root/.ssh/authorized_keys +if ! timeout 90 ssh -i /tmp/vkey -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + -p 22 root@127.0.0.1 'echo LOGIN-OK as $(whoami) on $(hostname); uname -m' > /tmp/sshout 2>&1; then + echo "FAIL: pubkey login did not succeed"; cat /tmp/sshout; exit 1 +fi +grep -v Warning /tmp/sshout +echo "--- password auth must be refused ---" +if timeout 30 ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + -o PubkeyAuthentication=no -o NumberOfPasswordPrompts=0 \ + -p 22 root@127.0.0.1 'echo SHOULD-NOT-PRINT' 2>/dev/null; then + echo "FAIL: password auth was accepted"; exit 1 +fi +echo " (refused, as expected)" +echo "--- sftp put/get test ---" +echo hello-spike2 > /work/sftp-test.txt +if ! timeout 60 sftp -P 22 -i /tmp/vkey -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + root@127.0.0.1 <<'SFTP' >/dev/null 2>&1 +put /work/sftp-test.txt /tmp/sftp-test.txt +get /tmp/sftp-test.txt /work/sftp-back.txt +SFTP +then + echo "FAIL: sftp session failed" + exit 1 +fi +cmp /work/sftp-test.txt /work/sftp-back.txt +echo " sftp round-trip OK" +echo "=== VALIDATE-ALL-OK ===" +exit 0 +TEST +chmod +x "$WORK/container-test.sh" + +if ! docker run --rm --privileged \ + -v "$WORK:/work:rw" \ + "$DOCKER_IMAGE" \ + /bin/sh /work/container-test.sh; then + err "container test failed" +fi + +say "PASS: key-only sshd boots from the image's own init script, pubkey login works, password auth refused, sftp works." +say "(scratch p2 and container artifacts deleted; your image file was only read)"