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
+537
View File
@@ -382,6 +382,543 @@ check("const bool wasLinked = _phase == Phase::WebSocket;" in close_fn,
"closeLink() treats anything that reached the WebSocket phase as able to leave "
"keys down, not only what is still marked authenticated")
print("one routing path, two transports")
# Phase 02 task 04 moved the TLS session underneath the routing instead of beside it. The
# claims are structural, so they are pinned as source sentences: what they defend is a
# second path that can disagree, and nothing that runs on a host can observe two paths
# agreeing until one of them is wrong.
conn_src = (root / "src" / "conn.h").read_text()
ws_src = (root / "src" / "ws_codec.h").read_text()
# readSome()'s rule, carried over from the TLS task and now the only clock-stamping read:
# bytes count as "the operator is alive" only from an authenticated controller on the
# WebSocket. Over TLS a handshake, a /status poll and a viewer refresh all look identical
# to the transport, so if this condition ever loosens to "bytes arrived", anything on the
# network can hold a dead controller's armed state open -- bug 10's shape again.
read_fn = server_src[server_src.index("size_t ControlServer::readSome"):
server_src.index("\n}", server_src.index("size_t ControlServer::readSome"))]
check("if (_phase == Phase::WebSocket && _authed) _lastInboundMs = millis();" in read_fn,
"readSome() stamps the silence clock only for an authenticated controller on the "
"WebSocket, whatever transport the bytes came over")
check("_lastInboundMs" in read_fn, "readSome() is still the code that notices inbound bytes")
# ... and it is the ONLY inbound path that may. Three writers of that clock would mean a
# route that can keep the board armed: readSome, and closeLink stamping on the way out.
stamps = [l.strip() for l in server_src.splitlines() if "_lastInboundMs = millis()" in l]
check_eq(len(stamps), 3,
"exactly three writers of the controller-silence clock: auth, readSome, closeLink")
check(any("the controller exists from here" in l for l in stamps),
"one of them is the authentication itself")
# The spare slot must not be able to reach that clock even by accident: it reads the Conn
# directly, never through readSome().
viewer_fn = server_src[server_src.index("void ControlServer::serveViewer"):
server_src.index("void ControlServer::stopViewer")]
# Code lines only: the function carries a comment explaining why it does NOT go through
# readSome(), and a check that flagged the comment would be worse than no check at all.
viewer_code = "\n".join(l for l in viewer_fn.splitlines()
if not l.strip().startswith(("//", "*", "/*")))
check("readSome" not in viewer_code,
"the read-only slot reads its Conn directly, so it cannot refresh the controller clock")
# One HTTP response writer. The task's completion criterion is a grep that comes up empty,
# so this is that grep, as a check: exactly one formatter in src/, and the only literal
# response status line outside it is the 101 protocol switch (which is not a response).
writers = sorted(p.name for p in (root / "src").glob("*")
if '"HTTP/1.1 ' in p.read_text(errors="replace"))
check_eq(writers, ["conn.h", "control_server.cpp"],
"only conn.h's formatter and the 101 upgrade line write an HTTP status line")
check_eq(server_src.count('"HTTP/1.1 '), 1,
"control_server.cpp holds exactly one, the WebSocket 101 -- not a second response writer")
check("respondAndClose(rsp)" in server_src and "respondViewer(rsp)" in server_src,
"both slots queue a built response through the same pair of writers")
# serveReadonly is the single read-only answer, reachable from both listeners/slots.
check_eq(server_src.count("serveReadonly(req, rsp,"), 2,
"the page/status routing is reached from the controller slot and the spare, once each")
check("void ControlServer::serveReadonly" in server_src,
"and it is defined once")
check_eq(server_src.count("plaintextRoute("), 1,
"the :80 answer table is consulted in exactly one place, inside serveReadonly")
# Both slots go through the transport view; nothing in the routing consults the port.
check("_ctl.read(" in conn_src or "_ctl.read(" in server_src or "_plain->read" in conn_src,
"the controller slot reads through kr::Conn")
check("_view.read(" in server_src, "the spare slot reads through kr::Conn too")
check("_server.hasClient()" in server_src and "::accept(_tlsListen" in server_src,
"both listeners still feed one accept decision")
# The :80 policy itself is pure (kr::plaintextRoute, walked in tests/test_conn.cpp); these
# pins keep the firmware from re-deciding it locally.
check("PlainRoute::UpgradeRequired" in conn_src and "PlainRoute::Redirect" in conn_src,
"conn.h spells out the redirect and the 426 as data, not as scattered ifs")
check("namespace kr" in conn_src, "conn.h is in namespace kr, like every other module")
check("token=" in conn_src and "The value is never copied" in conn_src,
"the token-in-URL test looks for the key without copying the passphrase")
# The Host header had to be parsed to build a Location that keeps the operator's address.
check('ciEquals(ls, "host:", 5)' in ws_src, "ws_codec parses Host: for the redirect")
check("char host[" in ws_src, "and carries it on Request")
# What the terminal client says when the board wants TLS. A refusal that reads like a
# passphrase problem sends the operator re-typing a correct secret until the board locks it.
check("board refuses plaintext" in relayctl.handshake_refusal("HTTP/1.1 426 Upgrade Required"),
"426 is named as a TLS refusal")
check("passphrase" not in relayctl.handshake_refusal("HTTP/1.1 426 Upgrade Required").lower(),
"and the 426 wording does not blame the passphrase")
check("passphrase" in relayctl.handshake_refusal("HTTP/1.1 401 Unauthorized"),
"401 still says what it always said")
check(relayctl.handshake_refusal("HTTP/1.1 503 Service Unavailable")
!= relayctl.handshake_refusal("HTTP/1.1 426 Upgrade Required"),
"'busy' and 'this board wants TLS' are never the same sentence")
check("https" in relayctl.handshake_refusal("HTTP/1.1 301 Moved Permanently"),
"a redirect on :80 is reported as the port policy it is")
for line, want in (("HTTP/1.1 418 Teapot", "refused"), ("garbage", "refused")):
check_eq(relayctl.handshake_refusal(line), want, f"unknown status {line!r}")
# The page the transport sends: the byte count tests/test_conn.cpp drains through the
# chunk arithmetic has to be the page that is actually in the tree. The page is a
# chain of raw-string chunks joined by one #if KR_TLS build marker (task 05), so
# "the page" is the chunks plus exactly one marker branch -- and both branches are
# asserted to be the same length, which is what keeps every pinned count (this one,
# the gate's, the device's `wc -c`) build-independent. That is a test, not a hope.
def served_page(src, tls=True):
"""The bytes one build serves: the raw-string chunks concatenated around the
marker branch that build takes. None when the layout is not chunks joined by
markers, so a restructured page fails loudly instead of counting half of it."""
chunks = re.findall(r'R"HTML\((.*?)\)HTML"', src, re.S)
marks = re.findall(r'#if KR_TLS\n\s*"([^"]*)"\n#else\n\s*"([^"]*)"\n#endif', src)
if len(chunks) < 1 or len(marks) != len(chunks) - 1:
return None
out = ""
for i, chunk in enumerate(chunks):
out += chunk
if i < len(marks):
out += marks[i][0] if tls else marks[i][1]
return out
page_tls = served_page(page_src, True)
page_plain = served_page(page_src, False)
check(page_tls is not None and page_plain is not None,
"every join between the page's raw-string chunks is one #if KR_TLS marker")
check(all(len(a) == len(b) for a, b in
re.findall(r'#if KR_TLS\n\s*"([^"]*)"\n#else\n\s*"([^"]*)"\n#endif',
page_src)),
"both branches of every build marker are the same length, so the served page "
"is the same size whichever build flashes")
check(page_tls is not None and "var KR_TLS_BUILD=1;" in page_tls,
"the TLS build's page carries its marker")
check("var KR_TLS_BUILD=0;" in page_plain,
"and the -D KR_TLS=0 rollback page carries its own")
check_eq(len(page_tls.encode("utf-8")), len(page_plain.encode("utf-8")),
"the two builds serve byte-count-equal pages")
pinned_page_bytes = int(re.search(r"kServedPageBytes = (\d+)",
(root / "tests" / "test_conn.cpp").read_text()).group(1))
check_eq(len(page_tls.encode("utf-8")), pinned_page_bytes,
"the page the chunk arithmetic is tested against is the page in web_page.h")
# The page's only scheme choice is its own origin; a TLS board never has an http: origin
# because :80 answers a redirect (pinned above, walked in test_conn).
check(page_src.count('"wss://"') == 1 and page_src.count('"ws://"') == 1,
"the page derives the socket scheme from location.protocol, in exactly one place")
check('location.protocol === "https:" ? "wss://" : "ws://"' in page_src,
"https: origin gives wss://, and ws:// is reachable only on a KR_TLS=0 build")
# The guard the other way round (task 05): a TLS build's page on an http: origin
# refuses to connect and refuses the passphrase. The behaviour is walked in
# tests/test_page.js; these pins keep the refusal from being edited into a warning.
check("function servedInClear()" in page_src and "if (servedInClear())" in page_src,
"the page asks whether it arrived in the clear, in connect() and at load")
check("this page arrived in the clear; reload over https" in page_src,
"and it says why, in words the operator can act on")
check('$("token").disabled = true' in page_src,
"a clear-served page will not even take passphrase input")
check("paintChannel(s)" in page_src and 'id="certFp"' in page_src
and 'id="pTls"' in page_src,
"the status frame's tls/fp pair is rendered: a pill and a certificate line")
print("the client side of pinning")
# The half of phase 02 the operator types. None of this may guess: no TLS argument
# means no connection, a wrong fingerprint means nothing was sent, and the only
# path to the plaintext port is the flag that says it knows what it is doing.
import contextlib # noqa: E402
import hashlib # noqa: E402
import io # noqa: E402
import os # noqa: E402
import socket as socketmod # noqa: E402
# --- the decision functions, walked directly ---------------------------------
check_eq(relayctl.select_transport("a" * 64, "", False, False), "pin",
"a fingerprint pins")
check_eq(relayctl.select_transport("", "/tmp/ca.pem", False, False), "ca",
"a CA file verifies a chain")
check_eq(relayctl.select_transport("", "", True, False), "insecure",
"--insecure is a decision, not a default")
check_eq(relayctl.select_transport("", "", False, True), "plain",
"--plain is the rollback path")
for bad, why in ((("", "", False, False), "no trust argument at all"),
(("a" * 64, "/tmp/ca.pem", False, False), "a pin and a CA at once"),
(("", "", True, True), "--insecure together with --plain"),
(("a" * 64, "", False, True), "a pin on the rollback path")):
try:
relayctl.select_transport(*bad)
check(False, f"select_transport should refuse: {why}")
except relayctl.RelayError as exc:
check(True, f"select_transport refuses: {why}")
if why == "no trust argument at all":
for option in ("--fingerprint", "--ca", "--insecure", "--plain"):
check(option in str(exc),
f"the refusal names {option} as a way forward")
check_eq(relayctl.resolve_port("pin", None), 443, "a pinned run defaults to :443")
check_eq(relayctl.resolve_port("ca", None), 443, "a verified run defaults to :443")
check_eq(relayctl.resolve_port("insecure", None), 443,
"even an unverified TLS run defaults to the TLS port")
check_eq(relayctl.resolve_port("plain", None), 80,
"--plain is the only path to :80")
check_eq(relayctl.resolve_port("pin", 8443), 8443, "an explicit --port wins")
check_eq(relayctl.normalize_fingerprint("A" * 64), "a" * 64,
"a bare hex fingerprint normalises to lowercase")
check_eq(relayctl.normalize_fingerprint(":".join("a1" for _ in range(32))), "a1" * 32,
"OpenSSL's colon pairs are accepted")
check_eq(relayctl.normalize_fingerprint(" " + "ab" * 32 + "\n"), "ab" * 32,
"and so is the whitespace a serial console wraps in")
for bad in ("", "deadbeef", "zz" * 32, "ab" * 31, "ab" * 32 + "0"):
try:
relayctl.normalize_fingerprint(bad)
check(False, f"a malformed fingerprint is refused: {bad[:12]!r}")
except relayctl.RelayError:
check(True, f"malformed fingerprint refused: {bad[:12]!r}")
# --- the connection itself, against a fake board ------------------------------
FAKE_DER = b"-----BEGIN FAKE CERTIFICATE----- der-bytes pinning never sees"
class FakeBoardSock:
"""Records every byte this side sends; answers the upgrade and `T<token>`.
The recorded frame list is the point: "the pin aborted before T<token>" can
only be shown by what did NOT go out.
"""
def __init__(self, host, port, close_early=False, bad_accept=False, flood=False):
self.host, self.port = host, port
self.bad_accept = bad_accept # answer the upgrade with a wrong accept hash
self.flood = flood # answer with headers that never terminate
self.sent = [] # every sendall payload, in order
self.inbox = bytearray()
self.upgraded = False
self.post = bytearray() # bytes after the 101 (client WS frames)
self.closed = False
self.timeout = None
self.close_early = close_early # answer the upgrade with a clean EOF
def settimeout(self, t):
self.timeout = t
def sendall(self, data):
data = bytes(data)
self.sent.append(data)
if self.close_early:
return # received, but the board answers nothing
if not self.upgraded:
blob = b"".join(self.sent)
if b"\r\n\r\n" in blob:
m = re.search(rb"Sec-WebSocket-Key: (\S+)", blob)
if self.flood:
self.inbox += b"HTTP/1.1 101 " + b"x" * 20000
self.upgraded = True
return
accept = (b"not-the-accept" if self.bad_accept
else relayctl.expect_accept(m.group(1).decode()).encode())
self.inbox += (b"HTTP/1.1 101 Switching Protocols\r\n"
b"Sec-WebSocket-Accept: " + accept + b"\r\n\r\n")
self.upgraded = True
return
self.post += data
while len(self.post) >= 2:
length = self.post[1] & 0x7F
key = self.post[2:6]
payload = bytes(b ^ key[i & 3] for i, b in enumerate(self.post[6:6 + length]))
del self.post[:6 + length]
if payload.startswith(b"T"):
self.inbox += relayctl_server_frame(relayctl.OP_TEXT, b"OK")
def recv(self, n):
if self.inbox:
data = bytes(self.inbox[:n])
del self.inbox[:n]
return data
if self.close_early:
return b"" # clean EOF: the board hung up mid-handshake
raise socketmod.timeout("fake board: no more bytes right now")
def close(self):
self.closed = True
def relayctl_server_frame(opcode, payload):
out = bytearray([0x80 | opcode, len(payload)])
out += payload
return bytes(out)
class FakeSsl:
"""Stand-in for the ssl module: real policy code runs against these knobs, and
wrap_socket returns the same socket with a getpeercert of our choosing."""
PROTOCOL_TLS_CLIENT = "tls-client"
CERT_NONE = 0
CERT_REQUIRED = 2
class SSLError(Exception):
pass
def __init__(self, der=FAKE_DER, wrap_error=None, empty_der=False):
self.der = der
self.wrap_error = wrap_error # raised by wrap_socket, simulating a failed TLS
self.empty_der = empty_der # peer completes TLS but shows no certificate
self.contexts = [] # (kind, ctx) for every context built
def SSLContext(self, protocol):
ctx = FakeSsl.Ctx(self, protocol)
self.contexts.append(("manual", ctx))
return ctx
def create_default_context(self, cafile=None):
ctx = FakeSsl.Ctx(self, "default")
ctx.cafile = cafile
self.contexts.append(("default", ctx))
return ctx
class Ctx:
def __init__(self, owner, protocol):
self.owner, self.protocol = owner, protocol
self.check_hostname = None
self.verify_mode = None
self.cafile = None
self.server_hostname = None
def wrap_socket(self, sock, server_hostname=None):
self.server_hostname = server_hostname
if self.owner.wrap_error is not None:
raise self.owner.wrap_error
der = b"" if self.owner.empty_der else self.owner.der
sock.getpeercert = lambda binary_form=False: der if binary_form else {}
return sock
def connect_with(fingerprint="", ca="", insecure=False, plain=False, port=None,
der=FAKE_DER, token="sekrit", wrap_error=None, close_early=False,
empty_der=False, bad_accept=False, flood=False,
real_create=socketmod.create_connection, real_ssl=relayctl.ssl):
fake = FakeSsl(der, wrap_error=wrap_error, empty_der=empty_der)
socks = []
def fake_create(addr, timeout):
s = FakeBoardSock(addr[0], addr[1], close_early=close_early,
bad_accept=bad_accept, flood=flood)
socks.append(s)
return s
relayctl.ssl = fake
relayctl.socket.create_connection = fake_create
stderr = io.StringIO()
relay, err = None, None
try:
with contextlib.redirect_stderr(stderr):
relay = relayctl.Relay("keyrelay.local", port, token, 1.0,
fingerprint=fingerprint, ca=ca,
insecure=insecure, plain=plain)
except relayctl.RelayError as exc:
err = exc
finally:
relayctl.ssl = real_ssl
relayctl.socket.create_connection = real_create
return relay, err, socks, fake, stderr.getvalue()
def client_frame_payloads(sock):
"""Decode the masked client frames this side sent, skipping the HTTP upgrade."""
blob = b"".join(sock.sent)
tail = blob.split(b"\r\n\r\n", 1)[1] if b"\r\n\r\n" in blob else b""
out = []
while len(tail) >= 2:
length = tail[1] & 0x7F
key = tail[2:6]
out.append(bytes(b ^ key[i & 3] for i, b in enumerate(tail[6:6 + length])))
tail = tail[6 + length:]
return out
# A wrong fingerprint aborts BEFORE a single frame -- above all before T<token>.
_wrong = "0" * 64
relay, err, socks, fake, warns = connect_with(fingerprint=_wrong)
check(isinstance(err, relayctl.RelayError), "a wrong fingerprint raises")
check(err is not None and "fingerprint mismatch" in str(err),
"and says exactly what mismatched")
check(socks and socks[0].sent == [],
"nothing at all went out -- not the upgrade, and certainly not T<token>")
# The right fingerprint (in any sane spelling) connects, and the passphrase frame
# is the first thing on the encrypted wire.
_right = hashlib.sha256(FAKE_DER).hexdigest()
relay, err, socks, fake, warns = connect_with(fingerprint=_right)
check(err is None and relay is not None, f"the pinned fingerprint connects ({err})")
check(relay is not None and relay.mode == "pin", "the mode is the pin")
if socks:
frames = client_frame_payloads(socks[0])
check(frames and frames[0] == b"Tsekrit",
"the first frame on a pinned session is the authentication")
check(b"Tsekrit" not in b"".join(socks[0].sent),
"the token never travels as plaintext bytes -- it is only in masked frames")
check(socks and socks[0].port == 443, "pinning dials :443")
relay2, err2, socks2, _, _ = connect_with(
fingerprint=":".join(_right[i:i + 2] for i in range(0, 64, 2)).upper())
check(err2 is None, "a colon-separated uppercase pin is the same pin")
# TLS failures, in the two shapes the operator can actually act on.
relay, err, socks, fake, warns = connect_with(
fingerprint=_right, wrap_error=FakeSsl.SSLError("handshake refused"))
check(err is not None and "TLS handshake with keyrelay.local failed" in str(err),
"a failed TLS handshake is named as that")
check(err is not None and "426" in str(err),
"and it names the plaintext-port mistake without ever proposing to make it")
check(socks and socks[0].closed, "and the socket is closed on the way out")
relay, err, socks, fake, warns = connect_with(
ca="/tmp/ca.pem", wrap_error=FakeSsl.SSLError("certificate verify failed"))
check(err is not None and "failed verification" in str(err),
"a --ca verification failure is not collapsed into 'handshake failed'")
check(err is not None and "nvs erase" in str(err),
"and it says the two things a verify failure can mean")
relay, err, socks, fake, warns = connect_with(fingerprint=_right, empty_der=True)
check(err is not None and "no certificate" in str(err),
"a TLS peer that shows no certificate is refused, not hashed as empty")
check(socks and socks[0].closed, "and that socket is closed too")
# --plain against a board that hangs up instead of speaking WebSocket.
relay, err, socks, fake, warns = connect_with(plain=True, close_early=True)
check(err is not None and "server closed during the handshake" in str(err),
"a closed handshake is reported for what it is")
relay, err, socks, fake, warns = connect_with(plain=True, bad_accept=True)
check(err is not None and "Sec-WebSocket-Accept" in str(err),
"a wrong accept hash refuses -- the RFC 6455 check is not decoration")
relay, err, socks, fake, warns = connect_with(plain=True, flood=True)
check(err is not None and "handshake response too large" in str(err),
"and a header block that never ends is bounded, not buffered forever")
# --ca verifies with OpenSSL's own machinery: hostname checking ON, chain REQUIRED.
relay, err, socks, fake, warns = connect_with(ca="/tmp/board-ca.pem")
check(err is None, f"--ca connects ({err})")
kinds = [k for k, _ in fake.contexts]
check(kinds == ["default"], "--ca builds its context with create_default_context")
ctx = fake.contexts[0][1]
check_eq(ctx.cafile, "/tmp/board-ca.pem", "and loads the file the operator named")
check_eq(ctx.check_hostname, True, "--ca keeps hostname checking on")
check_eq(ctx.verify_mode, 2, "and requires the chain")
check(ctx.server_hostname == "keyrelay.local",
"the hostname travels -- SNI and the check share one value")
# --insecure: connects, warns, and warns on EVERY run (it is stderr, not a memory).
relay, err, socks, fake, warns = connect_with(insecure=True)
check(err is None, "--insecure connects")
check("--insecure" in warns and "not verifying" in warns,
"--insecure is loud about what it skipped")
check_eq(socks[0].port, 443, "and still uses the TLS port")
_, _, _, fake_p, warns_p = connect_with(plain=True)
check(fake_p.contexts == [], "--plain builds no TLS context at all")
check(warns_p == "", "--plain warns about nothing it did not skip")
# --plain is the only way to :80, and it is explicit every time.
relay, err, socks, fake, warns = connect_with(plain=True)
check(err is None and socks[0].port == 80, "--plain reaches :80")
frames = client_frame_payloads(socks[0])
check(frames and frames[0] == b"Tsekrit",
"the rollback session authenticates the same way")
# --- main(): the refusals that need no board -----------------------------------
def run_main(argv, fp_env=None, token_env="hunter2secret"):
"""Run main() with the transport faked: create_connection records the address
instead of dialing, and ssl is the fake from above. Any credential that
reaches the returned text is a test failure, so the caller never has to
trust this helper to scrub it."""
calls = []
def record_create(addr, timeout):
calls.append(addr)
return FakeBoardSock(addr[0], addr[1])
old_argv = sys.argv
old_env = os.environ.get("KEYRELAY_TLS_FP"), os.environ.get("KEYRELAY_TOKEN")
sys.argv = ["relayctl.py"] + argv
if fp_env is None:
os.environ.pop("KEYRELAY_TLS_FP", None)
else:
os.environ["KEYRELAY_TLS_FP"] = fp_env
if token_env is None:
os.environ.pop("KEYRELAY_TOKEN", None)
else:
os.environ["KEYRELAY_TOKEN"] = token_env
real_ssl_main = relayctl.ssl
relayctl.ssl = FakeSsl()
relayctl.socket.create_connection = record_create
out, err_stream = io.StringIO(), io.StringIO()
code, exc = None, None
try:
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err_stream):
code = relayctl.main()
except BaseException as e: # noqa: BLE001 -- reported, then re-raised below
exc = e
finally:
sys.argv = old_argv
for name, value in zip(("KEYRELAY_TLS_FP", "KEYRELAY_TOKEN"), old_env):
if value is None:
os.environ.pop(name, None)
else:
os.environ[name] = value
relayctl.socket.create_connection = real_create_main
relayctl.ssl = real_ssl_main
if exc is not None:
raise exc
return code, out.getvalue(), err_stream.getvalue(), calls
real_create_main = socketmod.create_connection
code, out, err_text, calls = run_main(["--host", "keyrelay.local", "status"])
check_eq(code, 2, "no trust argument: main refuses with a usage error")
check(calls == [], "and refuses without opening a socket")
for option in ("--fingerprint", "--ca", "--insecure", "--plain"):
check(option in err_text, f"the refusal names {option}")
check("hunter2secret" not in err_text and "hunter2secret" not in out,
"the refusal prints no credential")
code, out, err_text, calls = run_main(["--fingerprint", "nothex", "status"])
check_eq(code, 2, "a malformed pin is refused before the network too")
check(calls == [] and "SHA-256" in err_text, "with the shape of what a pin must be")
# $KEYRELAY_TLS_FP is honoured like $KEYRELAY_TOKEN: the gate passes and the
# socket that follows is the board's. The fake's DER does not match the pin,
# so the run ends at the mismatch -- proof the pin is wired through main, and
# the token (never echoed) stays in the terminal.
code, out, err_text, calls = run_main(["--timeout", "0.1", "status"],
fp_env="dead" * 16)
check(calls == [("keyrelay.local", 443)],
"$KEYRELAY_TLS_FP satisfied the gate and selected :443")
check(code == 1 and "fingerprint mismatch" in err_text,
"and a pin that does not match still aborts the run, through main()")
check("hunter2secret" not in err_text and "hunter2secret" not in out,
"the passphrase is not printed on the abort path")
code, out, err_text, calls = run_main(["paste"], fp_env="ab" * 32, token_env=None)
check_eq(code, 2, "the token pre-flight still runs before anything else")
check("passphrase" in err_text and calls == [],
"no token: refused in the terminal, exactly as before TLS")
code, out, err_text, calls = run_main(["paste"], fp_env="ab" * 32)
check_eq(code, 4, "paste still needs --yes before any connection")
check(calls == [], "and that check still does not require a reachable board")
print("the target-side verifier")
# Twice now the verifier itself was the bug -- mislabelled LEDs, unnamed key codes that
# looked like lost keystrokes -- so the thing that decides what "it arrived" means gets