Files
keyrelay/tools/hw_probe.sh
T
ducoterra e0780f58e0 phase: 02_tls_control_channel
**Verified / implemented (phase 02 final pass)**
- All 6 tasks already in `complete/` → verification pass. Device measurement **found a real defect (README bug 11)**: the cert carried its SAN *value* under the OID written from the ASCII text `"2.5.29.17"` instead of DER content bytes `55 1D 11` (`openssl x509 -text` showed `1.10.46.53…`, hex `30 2f 06 09 32 2e 35…`), i.e. **no SAN** → hostname validation impossible while every host check stayed green (they walked the value, never the label).
- Fixed in the pure module: `kSanOidTlv`/`sanOidDer()`, `sanExtensionFullDer()` (pinned to the 43 bytes OpenSSL writes for these names), `derCarriesSan()` (OID *and* value); `tls.cpp` uses them + 3 `static_assert`s; `cert` now warns and names the remedy. 33 new checks in `test_tls_pem.cpp`, incl. the pre-fix extension byte-for-byte (`derContains` yes / `derCarriesSan` no).
- Corrected a false finding: the earlier "fingerprint rotated once" was an **OpenSSL `-ext` artifact** (`C8:76…` vs the real `61:54:19:30…`, same DER). Re-measured: serial `cert` == live handshake DER hash; page 51 626 B, `sha256 dd16e195…f04e9` == locally assembled page.

**Tests / lint (100 % green)**
- `bash .agents/validate.sh` → **GATE_EXIT=0**: `RESULT: all suites passed` in the plain *and* `SANITIZE=1` run, **4826 checks / 0 failures** each; `bridge`/`console`/`rollback` **SUCCESS**; `page bytes: 51626`; `README/AGENTS claims: OK`; `repo hygiene: OK (71 tracked files)`.
- Footprint: bridge **RAM 30.4 % (99 616 B) / Flash 39.6 % (1 246 145 B)** vs baseline 26.7 %/87 592 & 36.1 %/1 135 293 → **+12 024 B RAM / +110 852 B flash**; TLS alone +6 160 B / +100 008 B (rollback image byte-identical); session heap 187 936 → ~145.5 k (1 session) → ~107.9 k (2).
- `ruff check --select F,E9 tools/ tests/` → All checks passed; `bash -n` clean (gate, runner, probe); `-Wall -Wextra` warning-free.

**Completion criteria**: 1 ✓ (gate green + RAM/Flash + session heap quoted); 2 ✓ as recorded (boot prints no fingerprint on the NVS-load path, `cert` prints on demand, identity survived reflashes, nothing about the key is printed anywhere — `nvs erase`→first-boot leg **not re-run**); 3 ✓ re-measured (51 626 == gate; `:80` → 301, no page bytes); 4 partial — 426/401/404 + pinning handshake measured, but arming/typing needs the operator's passphrase (`dflt:0`) and the target is this machine, so no input was sent; 5 ✓ (no `.pem/.key/.crt` tracked or on disk; hygiene block ran); 6 ✓ (`[env:rollback]` built every run + README section); 7 ✓.

**Documentation**: README — check count 4793→4826 (intro + Quick start), `test_tls_pem` 829→862 with new pins, footprint/TLS-cost rows re-measured, "Ten bugs"→"Eleven bugs" + new entry + both anchors, `cert` row, Troubleshooting names-warning row, `tls_pem.h` architecture entry, nine/ten pure-module wording. AGENTS.md (staleness only): "Ten bugs"→"Eleven bugs" ×2, 4826, and the certificate-names row extended with the DER-OID rule. `.agents/HW_VERIFICATION.md` — 426 body/headers re-measured, fingerprint row correction + `-ext` rule, new SAN row (`failed → fixed in tree, device leg needs nvs erase`) with closing commands; phase report written to `.agents/reports/02_tls_control_channel/02_tls_control_channel__00_phase.a1.md`.

**Deviations / live-infra**: made **no** reflash and **no** `nvs erase` (erase also destroys the token and Wi-Fi credentials; flashing puts a live HID keyboard into ROM download mode on this machine). Live steps were read-only `curl`/`openssl` plus one capped `console.py cert` (opening the CDC resets the board — permitted, recorded). No phase/PLAN files touched; everything left in the working tree for the harness to commit.

**Next pending phase**: `03_session_takeover` — `01_pending_claim_socket.md`.
2026-11-09 05:51:57 -05:00

187 lines
9.4 KiB
Bash
Executable File

#!/usr/bin/env bash
# tools/hw_probe.sh — what device context exists RIGHT NOW. It informs; it does
# not gate. Always exits 0, and .agents/validate.sh deliberately does NOT fold
# its exit status into the gate's rc.
#
# Why this exists (AGENTS.md "Verification standard", PLAN.md "No unit blocks on
# hardware"): device checks are the half of this roadmap's verification the host
# gate cannot see. They are best effort — one attempt, wrapped in a timeout, the
# outcome recorded in .agents/HW_VERIFICATION.md. This probe is the cheap way to
# find out whether attempting one is even plausible, so a unit running unattended
# never discovers the absence of a board by hanging on it.
#
# Rules this script obeys — the same rules the hardware contract states:
# * No side effects. It writes nothing and NEVER opens a serial port: opening
# /dev/ttyACM* asserts DTR/RTS and resets the board. `test -e`/`test -r` and
# read-only sysfs metadata only. HTTP(S) probes are idempotent GETs.
# * Never blocks. Every probe that could wait is capped with `timeout` or curl
# -m. getent CAN hang when no mDNS responder answers, so it runs only under
# `timeout` — if `timeout` itself is missing, the lookup is skipped with a
# stated reason rather than risked.
# * Always exits 0. Every fact is printed; none of them is a verdict. The
# gate result comes from .agents/validate.sh; device verdicts come from the
# outcome table in .agents/HW_VERIFICATION.md, filled in by whoever ran the
# actual command — never inferred from these lines.
#
# One line per fact, prefix "hw-probe", so the output stays greppable:
# serial nodes / keyrelay.local resolution / https / tls handshake (phase 02:
# protocol + cipher via openssl s_client) / http(+redirect) /
# uv / node / watch_target.py-capable evdev nodes.
set -uo pipefail
have() { command -v "$1" >/dev/null 2>&1; }
# to <secs> <cmd...> — run capped; rc 124 = the cap fired, 125 = cannot cap, so
# the caller must NOT run the command (an uncapped blocking probe is exactly
# what this file's rule forbids).
to() {
local secs="$1"
shift
if have timeout; then
timeout "$secs" "$@"
else
return 125
fi
}
# --- 1. serial nodes (existence and readability ONLY — never opened) ----------
serials=()
for candidate in /dev/ttyACM* /dev/ttyUSB*; do
[[ -e "$candidate" ]] && serials+=("$candidate")
done
if ((${#serials[@]} > 0)); then
for s in "${serials[@]}"; do
if [[ -r "$s" ]]; then
echo "hw-probe serial $s present, readable (NOT opened: opening toggles DTR/RTS and resets the board)"
else
echo "hw-probe serial $s present, NOT readable (serial group membership? console.py would fail)"
fi
done
else
echo "hw-probe serial no /dev/ttyACM* or /dev/ttyUSB* here — no board on a serial port"
fi
# --- 2. keyrelay.local (mDNS via the resolver; may legitimately be slow) ------
mdns_rc=0
mdns_out="$(to 4 getent hosts keyrelay.local 2>/dev/null)" || mdns_rc=$?
if ! have getent && ((mdns_rc != 0)); then
echo "hw-probe mdns unknown — getent not installed; resolution state not checkable here"
elif ((mdns_rc == 0)); then
echo "hw-probe mdns keyrelay.local resolves -> ${mdns_out%%[[:space:]]*}"
elif ((mdns_rc == 124)); then
echo "hw-probe mdns keyrelay.local did not resolve within 4 s (no responder? glibc does not cache mDNS — a retry may still work; the IP is the fallback)"
elif ((mdns_rc == 125)); then
echo "hw-probe mdns unknown — 'timeout' is not installed and an uncapped resolver lookup can hang; skipped on principle"
else
echo "hw-probe mdns keyrelay.local did not resolve"
fi
# --- 3./4. reachability of the two listeners -----------------------------------
# Expectations shift when phase 02 lands: until then :80 serves the page and
# :443 is closed; after it, :443 serves TLS and :80 is redirect-only. Both
# halves print what IS, and name the expectation, so neither state reads as a
# failure — this probe cannot fail, only inform.
if have curl; then
https_rc=0
https_code="$(curl -sk -m 2 -o /dev/null -w '%{http_code}' https://keyrelay.local/ 2>/dev/null)" || https_rc=$?
if [[ "$https_rc" == 0 && "$https_code" != 000 ]]; then
echo "hw-probe https https://keyrelay.local/ -> HTTP $https_code (TLS listener is up)"
else
echo "hw-probe https https://keyrelay.local/ unreachable (curl rc $https_rc, code ${https_code:-000}) — expected until phase 02 (TLS) lands"
fi
http_rc=0
http_fact="$(curl -s -m 2 -o /dev/null -w '%{http_code} %{redirect_url}' http://keyrelay.local/ 2>/dev/null)" || http_rc=$?
http_code="${http_fact%% *}"
http_redirect="${http_fact#* }"
if [[ "$http_rc" == 0 && "$http_code" == 3* && -n "$http_redirect" ]]; then
echo "hw-probe http http://keyrelay.local/ -> HTTP $http_code, redirects to $http_redirect (redirect-only: the post-phase-02 shape)"
elif [[ "$http_rc" == 0 && "$http_code" != 000 ]]; then
echo "hw-probe http http://keyrelay.local/ -> HTTP $http_code, no redirect (plaintext page still served: the pre-phase-02 shape)"
else
echo "hw-probe http http://keyrelay.local/ unreachable (curl rc $http_rc) — board off, asleep, or this host is not on its network"
fi
else
echo "hw-probe http skipped — curl not installed; https/http reachability unknown"
fi
# --- 4b. the TLS handshake's details (phase 02 task 03's informational probe) -----
# The phase asks for `openssl s_client -connect <ip>:443 -servername
# keyrelay.local` reporting protocol and cipher. Informational like everything
# else here: one attempt, capped, exit status ignored, no verdict. The verify
# failure it will print (self-signed, pinned-once identity) is the EXPECTED shape
# of a healthy keyrelay handshake — whether to trust that certificate is a human
# decision made against the serial console's fingerprint, never this line's.
if have openssl; then
tls_host="keyrelay.local"
[[ -n "${mdns_out:-}" ]] && tls_host="${mdns_out%%[[:space:]]*}"
tls_rc=0
tls_fact="$(to 5 openssl s_client -connect "${tls_host}:443" -servername keyrelay.local </dev/null 2>&1)" || tls_rc=$?
tls_proto="$(printf '%s\n' "$tls_fact" | sed -n 's/^[[:space:]]*Protocol[[:space:]]*:[[:space:]]*//p' | head -1)"
tls_cipher="$(printf '%s\n' "$tls_fact" | sed -n 's/^[[:space:]]*Cipher[[:space:]]*:[[:space:]]*//p' | head -1)"
if ((tls_rc == 125)); then
echo "hw-probe tls skipped — 'timeout' is not installed and an uncapped s_client can hang; skipped on principle"
elif [[ -n "$tls_cipher" && "$tls_cipher" != "(NONE)" ]]; then
echo "hw-probe tls ${tls_host}:443 negotiated ${tls_proto:-TLS?} / ${tls_cipher} (a self-signed verify failure is the expected shape; the fingerprint to compare it against is on the serial console)"
else
echo "hw-probe tls no TLS session on ${tls_host}:443 (rc $tls_rc) — expected until phase 02's listener is flashed; 'unknown ca' ABOVE would mean the session DID complete"
fi
else
echo "hw-probe tls skipped — openssl not installed; protocol/cipher state unknown"
fi
# --- 5./6. the two tools the gate and the suites pin ---------------------------
if have uv; then
echo "hw-probe uv $(command -v uv) — firmware builds available"
else
echo "hw-probe uv MISSING — the gate's firmware check will FAIL (AGENTS.md: pio on PATH is broken; fallback probe: find / -maxdepth 6 -name platformio -type d -path '*site-packages*')"
fi
if have node; then
echo "hw-probe node $(command -v node) — test_page.js runs"
else
echo "hw-probe node MISSING — tests/run_tests.sh would skip the page suite and the gate pins it; the gate will FAIL"
fi
# --- 7. watch_target.py-capable evdev nodes (informational, read-only) ---------
# Same discovery rule watch_target.py uses: USB devices with the board's ids
# (Logitech G815 identity, Espressif default accepted), nodes classified by the
# kernel's capabilities/ev mask, never by node order — the same reasoning that
# keeps the verifier honest. sysfs reads only; no /dev/input node is opened.
kb=""
ms=""
board_ids=""
for dev in /sys/bus/usb/devices/*/; do
[[ -r "${dev}idVendor" && -r "${dev}idProduct" ]] || continue
vid="$(<"${dev}idVendor")"
pid="$(<"${dev}idProduct")"
case "$vid:$pid" in
046d:c33f | 303a:1001) : ;;
*) continue ;;
esac
for ev in "$dev"*/input/input*/event* "$dev"*/*/input/input*/event*; do
[[ -e "$ev" ]] || continue
caps_file="${ev%/*}/capabilities/ev" # built-in, not `dirname` — works with any PATH
[[ -r "$caps_file" ]] || continue
mask_hex="$(<"$caps_file")"
[[ "$mask_hex" =~ ^[0-9a-fA-F]+$ ]] || continue
mask=$((16#$mask_hex))
node="/dev/input/${ev##*/}"
readable="readable"
[[ -r "$node" ]] || readable="NOT readable (input group?)"
if ((mask & 4)); then # EV_REL — the pointer interface
[[ -z "$ms" ]] && ms="$node ($readable)"
elif ((mask & 1)); then # EV_KEY — the keyboard interface
[[ -z "$kb" ]] && kb="$node ($readable)"
fi
board_ids="$vid:$pid"
done
done
if [[ -n "$kb$ms" ]]; then
echo "hw-probe evdev board $board_ids visible: keyboard=${kb:-none} mouse=${ms:-none} — python3 tools/watch_target.py runs ON THIS machine (the target)"
else
echo "hw-probe evdev no USB device with the board's ids (046d:c33f, 303a:1001) here — target-side claims are 'skipped (no device)' unless the board is plugged into another machine"
fi
# Deliberate, unconditional: this script informs and must never decide anything.
exit 0