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`.
This commit is contained in:
2026-11-09 05:51:57 -05:00
parent 121491bab4
commit e0780f58e0
61 changed files with 8860 additions and 359 deletions
+268 -27
View File
@@ -1,24 +1,42 @@
#!/usr/bin/env python3
"""relayctl -- talk to a keyrelay board from a terminal.
Stdlib only (socket, hashlib, base64, json): the board's protocol is a WebSocket of
tiny text commands, and the point of a keyboard bridge is that it needs nothing
installed to be useful.
Stdlib only (socket, ssl, hashlib, base64, json): the board's protocol is a
WebSocket of tiny text commands, and the point of a keyboard bridge is that it
needs nothing installed to be useful.
relayctl.py --host keyrelay.local status
relayctl.py --host 192.168.4.1 arm
relayctl.py --host 10.2.1.99 key Enter # one key, press and release
relayctl.py --host 10.2.1.99 mouse move 120 -40 # relative movement, split to fit
relayctl.py --host 10.2.1.99 mouse click right # press and release
relayctl.py --host 10.2.1.99 mouse drag 200 0 # press, move, release, in order
relayctl.py --host 10.2.1.99 cad # Ctrl+Alt+Del, with --yes
relayctl.py --host keyrelay.local listen # print every status heartbeat
relayctl.py --host keyrelay.local disarm
wl-paste | relayctl.py paste --yes # type what is on the clipboard
A TLS board (the default) needs exactly one trust argument, and refuses to run
without one -- this tool will not be the component that quietly downgrades:
--fingerprint <sha256> pin the board certificate (the `cert` command on the
board's serial console prints it; also $KEYRELAY_TLS_FP).
Verification is off during the handshake, then the peer's
DER is hashed and a mismatch aborts before a single
frame -- including T<token> -- is sent. Real pinning,
not TOFU, and nothing to distribute.
--ca <pem> verify against a CA (or a pinned self-signed cert file)
with hostname checking on.
--insecure no verification; prints a warning on every run.
--plain plaintext ws:// on :80, for -D KR_TLS=0 rollback boards
only. Opt-in, because the TLS board refuses that port
anyway (301 / 426).
relayctl.py --fingerprint $FP --host keyrelay.local status
relayctl.py --fingerprint $FP --host 192.168.4.1 arm
relayctl.py --fingerprint $FP --host 10.2.1.99 key Enter # one key, press+release
relayctl.py --fingerprint $FP --host 10.2.1.99 mouse move 120 -40 # split to fit
relayctl.py --fingerprint $FP --host 10.2.1.99 mouse click right # press+release
relayctl.py --fingerprint $FP --host 10.2.1.99 mouse drag 200 0 # in order
relayctl.py --fingerprint $FP --host 10.2.1.99 cad # Ctrl+Alt+Del, --yes
relayctl.py --fingerprint $FP --host keyrelay.local listen # status heartbeats
relayctl.py --fingerprint $FP --host keyrelay.local disarm
wl-paste | relayctl.py --fingerprint $FP paste --yes # type what is on the clipboard
The passphrase is taken from --token, then $KEYRELAY_TOKEN. It is never written to
disk and never put in a URL (the /status HTTP endpoint takes it as a query parameter
because curl is the point of that endpoint; this tool uses the WebSocket instead).
Over TLS both the passphrase and the keystrokes are encrypted in transit; the
fingerprint is what says the other end is *this* board rather than somebody's.
`paste` is the one command that types more than a single key, because sending text a
human copied is a thing a keyboard bridge is for. It is gated accordingly: --yes is
@@ -35,6 +53,15 @@ buttons and need --yes for that reason; a movement does not, because moving a cu
reversible by definition. There is no gesture recording, no auto-click and no "move to the
OK button", because the moment this tool could actuate a target on its own it would be a
different and worse device than a keyboard.
What the TLS in this tool does NOT do is make anything safe by itself. An encrypted session is
still only a controller that has to know the passphrase, and a session that drops is a lost link
the board answers by releasing every key and every mouse button. The certificate says *which*
board, and only if you asked -- which is why this tool's contribution is procedural: never pick
the weaker option for the operator. No fallback from wss:// to ws://, no default-on --insecure,
no "try :80 first", no quiet downgrade when a pin fails; every one of those is a decision the
operator makes in writing, and the failure text names the real choice instead of blaming the
passphrase for a transport problem.
"""
from __future__ import annotations
@@ -44,11 +71,13 @@ import hashlib
import json
import os
import socket
import ssl
import struct
import sys
import time
DEFAULT_PORT = 80
DEFAULT_PORT = 80 # plaintext ws:// -- reachable only via --plain
TLS_DEFAULT_PORT = 443 # wss:// on a KR_TLS=1 board
WS_PATH = "/ws"
# The whole point of `keys` is verifying the keystroke path in one armed session.
# A cap keeps it that: enough for a test vector, not a way to type a paragraph.
@@ -240,6 +269,141 @@ def expect_accept(key: str) -> str:
return base64.b64encode(digest).decode()
def handshake_refusal(status_line: str) -> str:
"""Why the board said no, phrased so the operator can act on it.
These shapes used to collapse into one "refused" and they are not the same problem:
426 the board refuses to speak plaintext at all. Its controller channel is
`wss://` on :443, and a `ws://` upgrade on :80 is answered rather than quietly
accepted -- invariant 5, never silently take the weaker option. The fix is a
trust argument (--fingerprint from the board's `cert`), not a different port.
301 the same refusal, for a request that was not an upgrade: the port redirects.
401 the endpoint needs a passphrase this path cannot carry.
503 another controller already holds the bridge.
Saying "wrong passphrase" when the board means "use TLS" is the failure this function
exists to prevent: the operator then re-types a passphrase that was never the problem,
five times, and locks the token out for kTokenLockMs on a board that was trying to
protect them. tests/test_relayctl.py pins that 426 and 401 do not share a sentence.
"""
code = ""
for part in status_line.split():
if len(part) == 3 and part.isdigit():
code = part
break
if code == "426":
return ("board refuses plaintext: its controller channel is wss:// on :443. "
"Re-run with a trust argument (--fingerprint <sha256> from the `cert` "
"command on the board's serial console, or --ca); --plain only reaches "
"a board built with -D KR_TLS=0")
if code == "301":
return ("board redirected to https://: it no longer serves content over plaintext "
"http -- use the TLS defaults (--fingerprint or --ca)")
if code == "401":
return "passphrase required (this endpoint does not take one)"
if code == "503":
return "another controller already holds the bridge"
if code == "400":
return "malformed request -- not a keyrelay WebSocket endpoint"
return "refused"
# ---------------------------------------------------------------------------
# TLS: how this client decides to trust the board, or refuses to guess
# ---------------------------------------------------------------------------
FINGERPRINT_HEX = 64 # one SHA-256, lowercase hex
def normalize_fingerprint(text: str) -> str:
"""Lowercase 64-hex of a SHA-256 fingerprint, whatever shape it was pasted in.
Accepts what the board's `cert` prints (bare hex) and what OpenSSL prints
(`AA:BB:...`); refuses anything else rather than pinning a typo. A pin that
silently means nothing is worse than no pin, because it reads like one.
"""
raw = "".join((text or "").split()).replace(":", "")
if len(raw) != FINGERPRINT_HEX or any(c not in "0123456789abcdef" for c in raw.lower()):
raise RelayError(
f"{text!r} is not a SHA-256 fingerprint: want {FINGERPRINT_HEX} hex "
f"digits (the `cert` command on the board's serial console prints it)")
return raw.lower()
def select_transport(fingerprint: str, ca: str, insecure: bool,
plain: bool) -> str:
"""One trust decision from the four switches: "pin" | "ca" | "insecure" | "plain".
Refuses, in two directions. None of them: refuse to connect at all -- a tool
that defaults to plaintext, or to trust-on-first-use, is the component that
quietly downgrades (AGENTS.md invariant 5), so the default posture is a
refusal that says what to pass. More than one: they are different trust
stories (a pin, a chain, no verification, no TLS), and picking one silently
is guessing.
"""
if plain:
if fingerprint or ca or insecure:
raise RelayError("--plain is the -D KR_TLS=0 rollback path; do not combine "
"it with a TLS trust argument (--fingerprint/--ca/--insecure)")
return "plain"
chosen = [name for name, on in (("--fingerprint", fingerprint), ("--ca", ca),
("--insecure", insecure)) if on]
if len(chosen) > 1:
raise RelayError(f"{' and '.join(chosen)} are different trust decisions; pass "
f"exactly one")
if not chosen:
raise RelayError(
"refusing to connect without saying how to trust the board: pass "
"--fingerprint <sha256> (or $KEYRELAY_TLS_FP; the `cert` command on the "
"board's serial console prints it), or --ca <pem>, or --insecure for an "
"unverified run this tool will warn about every time; --plain only for a "
"board built with -D KR_TLS=0")
if fingerprint:
return "pin"
return "ca" if ca else "insecure"
def resolve_port(mode: str, port) -> int:
"""Explicit --port always wins; otherwise TLS is 443 and --plain is 80.
One function because "which port did this flag combination pick" must not be
folklore spread across call sites -- pinning a certificate on the plaintext
port would be a very confident way to verify nothing.
"""
if port is not None:
return int(port)
return DEFAULT_PORT if mode == "plain" else TLS_DEFAULT_PORT
def build_tls_context(mode: str, ca: str = "") -> ssl.SSLContext:
"""The handshake policy for one TLS mode. Pinning and --insecure share the
handshake settings deliberately: the pin does its work AFTER the handshake,
on the DER the peer actually presented, so it works against a self-signed
certificate with no CA ceremony -- and an empty verification path cannot be
mistaken for a filled one. --ca is the opposite shape: OpenSSL verifies the
chain AND the hostname, because that is the option for operators who ran
their own CA on purpose.
"""
if mode == "ca":
ctx = ssl.create_default_context(cafile=ca)
ctx.check_hostname = True # the certificate is issued for keyrelay.local
ctx.verify_mode = ssl.CERT_REQUIRED
return ctx
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE # the pin hashes the peer cert itself; see above
return ctx
def peer_fingerprint(sock) -> str:
"""SHA-256 of the DER the peer presented -- the same value `cert` prints."""
der = sock.getpeercert(binary_form=True)
if not der:
raise RelayError("the TLS peer presented no certificate to fingerprint")
return hashlib.sha256(der).hexdigest()
def encode_frame(opcode: int, payload: bytes = b"") -> bytes:
"""Client frames are always masked (RFC 6455 5.1: a server must reject others)."""
out = bytearray([0x80 | opcode])
@@ -300,12 +464,62 @@ class Receiver:
class Relay:
def __init__(self, host: str, port: int = DEFAULT_PORT, token: str = "",
timeout: float = 5.0) -> None:
def __init__(self, host: str, port=None, token: str = "", timeout: float = 5.0,
fingerprint: str = "", ca: str = "", insecure: bool = False,
plain: bool = False) -> None:
self.host = host
self.timeout = timeout
self.token = token
self.sock = socket.create_connection((host, port), timeout)
# The transport decision happens before a socket exists: "no trust
# argument" must refuse in the terminal, not connect and find out.
self.mode = select_transport(fingerprint, ca, insecure, plain)
self.port = resolve_port(self.mode, port)
self.pinned = ""
if self.mode == "pin":
self.pinned = normalize_fingerprint(fingerprint)
if self.mode == "insecure":
# On every run, not once per install: an opt-in that stops announcing
# itself becomes a default.
print("WARNING: --insecure -- not verifying the board's certificate. "
"Anyone on this network can answer. Use --fingerprint (the `cert` "
"command on the serial console prints it) unless you know exactly "
"why you cannot.", file=sys.stderr)
self.sock = socket.create_connection((host, self.port), timeout)
if self.mode != "plain":
try:
ctx = build_tls_context(self.mode, ca=ca)
self.sock = ctx.wrap_socket(self.sock, server_hostname=host)
except (ssl.SSLError, OSError) as exc:
self.sock.close()
if self.mode == "ca":
raise RelayError(
f"TLS to {host} failed verification: {exc} -- a certificate "
f"verify failure usually means the board regenerated its "
f"identity (`nvs erase`) or you are talking to a different "
f"device; compare the fingerprint with `cert`") from exc
raise RelayError(
f"TLS handshake with {host} failed: {exc} -- if the board answered "
f"the plaintext port with a redirect or a 426, you are on the wrong "
f"port or the wrong device; a TLS keyrelay is on 443") from exc
if self.mode == "pin":
# Abort BEFORE a single frame -- above all before T<token>. A pin
# checked after the WebSocket handshake is a pin on the passphrase.
try:
got = peer_fingerprint(self.sock)
except RelayError:
self.sock.close()
raise
if got != self.pinned:
self.sock.close()
raise RelayError(
f"certificate fingerprint mismatch: {host} presented "
f"{got}, you pinned {self.pinned}. Nothing was sent. Either "
f"the board regenerated its identity (`nvs erase` reprints "
f"the new one once) or somebody else is answering -- which "
f"is exactly the attack this check exists for. Re-read the "
f"fingerprint with `cert` on the board's serial console.")
self.receiver = Receiver()
key = base64.b64encode(os.urandom(16)).decode()
@@ -338,12 +552,8 @@ class Relay:
if sep:
fields[name.strip().lower()] = value.strip()
if " 101 " not in status_line + " ":
detail = "refused"
if "401" in status_line:
detail = "passphrase required (this endpoint does not take one)"
elif "503" in status_line:
detail = "another controller already holds the bridge"
raise RelayError(f"handshake failed: {status_line} ({detail})")
raise RelayError(f"handshake failed: {status_line} "
f"({handshake_refusal(status_line)})")
want = expect_accept(key)
got = fields.get("sec-websocket-accept", "")
if got != want:
@@ -625,16 +835,32 @@ def describe(status: dict) -> str:
return "\n".join(lines)
def main() -> int:
def build_arg_parser() -> argparse.ArgumentParser:
"""The CLI surface, apart from main() so tests can parse without running."""
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--host", default="keyrelay.local",
help="board address (default keyrelay.local; on its own AP use "
"192.168.4.1)")
ap.add_argument("--port", type=int, default=DEFAULT_PORT)
ap.add_argument("--port", type=int, default=None,
help="override the port: default 443 over TLS, 80 with --plain")
ap.add_argument("--token", default=os.environ.get("KEYRELAY_TOKEN", ""),
help="control passphrase (or $KEYRELAY_TOKEN)")
ap.add_argument("--timeout", type=float, default=5.0)
ap.add_argument("--fingerprint", default=os.environ.get("KEYRELAY_TLS_FP", ""),
metavar="SHA256",
help="pin the board certificate: abort before sending anything "
"unless the peer's DER hashes to this (or $KEYRELAY_TLS_FP); "
"the serial `cert` command prints it")
ap.add_argument("--ca", default="", metavar="PEM",
help="verify the board certificate against this CA file (or "
"pinned self-signed certificate), hostname checking on")
ap.add_argument("--insecure", action="store_true",
help="connect without any verification; warns on stderr on "
"every run -- a one-off, not a habit")
ap.add_argument("--plain", action="store_true",
help="plaintext ws:// on port 80: only for boards built with "
"-D KR_TLS=0 (the rollback); a TLS board refuses this")
sub = ap.add_subparsers(dest="cmd", required=True)
sub.add_parser("status", help="print what the board reports")
@@ -701,8 +927,11 @@ def main() -> int:
p_keys.add_argument("--keep-armed", action="store_true",
help="stay armed afterwards (the board still disarms on "
"silence 8 s after this process exits)")
return ap
args = ap.parse_args()
def main() -> int:
args = build_arg_parser().parse_args()
if not args.token:
print("no passphrase: --token or $KEYRELAY_TOKEN", file=sys.stderr)
return 2
@@ -752,8 +981,20 @@ def main() -> int:
f"({MAX_PASTE_CHARS}); send it in pieces", file=sys.stderr)
return 2
# Settled locally too: "you gave me no way to trust the board" is a usage
# answer, not a connection failure -- and refusing here means no socket was
# ever opened to find that out.
try:
relay = Relay(args.host, args.port, args.token, args.timeout)
if select_transport(args.fingerprint, args.ca, args.insecure, args.plain) == "pin":
normalize_fingerprint(args.fingerprint)
except RelayError as exc:
print(str(exc), file=sys.stderr)
return 2
try:
relay = Relay(args.host, args.port, args.token, args.timeout,
fingerprint=args.fingerprint, ca=args.ca,
insecure=args.insecure, plain=args.plain)
except (OSError, RelayError) as exc:
print(f"cannot use the board at {args.host}: {exc}", file=sys.stderr)
return 1