**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`.
187 lines
9.4 KiB
Bash
Executable File
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
|