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
+286
View File
@@ -0,0 +1,286 @@
/**
* tests/test_tls_session.cpp -- the policy half of src/tls_session.h, walked on the
* host. No mbed TLS, no Arduino, no LWIP: the header's pure section is exactly the
* part that must compile without them (the Session class hides behind
* KR_TLS && ESP_PLATFORM), and this file is the proof. If this suite ever needs an
* mbedtls include to keep passing, the split has failed -- see AGENTS.md.
*
* What each check pins, and the failure it is defending:
*
* deadlinePassed -- the handshake/stall timeouts are uint32 clock maths; a plain
* `now - start > limit` that is not written wrap-safe silently
* un-timeouts every connection around the 49.7-day rollover.
* admitConnection -- the kTlsMaxSessions refusal. The three outcomes and the order
* they are decided in: the cap BEFORE the free slot, so an
* over-cap board refuses rather than "finds" a slot that memory
* was not reserved for. The refusal must not know which listener
* a socket came from -- there is no transport argument here at
* all, which is the point.
* mapIo -- the table that decides "would block" vs "peer gone" vs "fatal"
* for every mbedtls_ssl_* return. A WANT misread as fatal drops
* working controllers; a fatal misread as WANT spins the loop.
* The three literals it branches on are pinned against the real
* library by static_assert in tls_session.cpp -- here the TABLE
* is walked; there the NUMBERS are welded.
* renderFatalAlert-- 7 bytes of somebody else's wire format (RFC 5246 alert record).
* AGENTS.md's evdev lesson: test the table, not your confidence.
* TxRing -- whole-frame-or-nothing staging. A half-queued WebSocket frame
* corrupts the keystroke channel; this class makes that the only
* failure it cannot produce. It is now the outbound queue of BOTH
* transports (kr::Conn, src/conn.h), not just the TLS one.
*
* Phase 02 task 04 removed `WriteQueue` from this header: the transport view in conn.h
* queues a head and a body through the ring plus kr::FlashSend instead, so keeping a second
* streaming cursor here would be exactly the second path this phase exists to delete. The
* "stream one long segment in bounded pieces" coverage moved with it, to tests/test_conn.cpp.
*/
#include <string.h>
#include "test.h"
#include "tls_session.h"
using kr::tls::admitConnection;
using kr::tls::Admit;
using kr::tls::deadlinePassed;
using kr::tls::Io;
using kr::tls::kFatalAlertLen;
using kr::tls::kMbedPeerCloseNotify;
using kr::tls::kMbedWantRead;
using kr::tls::kMbedWantWrite;
using kr::tls::mapIo;
using kr::tls::renderFatalAlert;
using kr::tls::TxRing;
int main() {
// ---------------------------------------------------------------- deadline
CASE("deadline: basics");
CHECK(!deadlinePassed(1000, 1000, 5000)); // just started
CHECK(!deadlinePassed(5999, 1000, 5000)); // one ms short
CHECK(!deadlinePassed(6000, 1000, 5000)); // exactly the limit has NOT passed
CHECK(deadlinePassed(6001, 1000, 5000)); // and one past it has
CHECK(deadlinePassed(1, 0, 0)); // zero-length limit: instantly stale
CASE("deadline: uint32 clock rollover");
// The board's millis() wraps at 2^32. All four of these evaluate "backwards" under
// unsigned arithmetic and only read correctly through the signed-delta form.
constexpr uint32_t kNear = 0xFFFFFFF0u; // 16 ms before wrap
CHECK(!deadlinePassed(kNear + 10, kNear, 64)); // 10 ms in, no wrap yet
CHECK(!deadlinePassed(0x10, kNear, 32)); // wrapped: 32 ms in, limit 32...
CHECK(deadlinePassed(0x20, kNear, 16)); // wrapped: 48 ms > 16 ms limit
CHECK(deadlinePassed(0x0FFFFFFF, 0x10, 0xFFFFFFF0)); // 0xFFFFFFF0 elapsed, limit hit
CHECK(deadlinePassed(0x0FFFFFFF, 0x10, 0xFFFFFFF0 - 1));
CHECK(!deadlinePassed(5000, 1000, 0x7FFFFFFF)); // huge limit stays sane
CASE("deadline: handshake shape");
// kTlsHandshakeMs is the scan bound: "opened and never finished a handshake" means
// exactly this comparison going true, every tick, from Session::poll().
CHECK(deadlinePassed(10001, 0, kr::kTlsHandshakeMs));
CHECK(!deadlinePassed(10000, 0, kr::kTlsHandshakeMs));
CHECK_EQ(kr::kTlsHandshakeMs, 10000);
// ------------------------------------------------------------------ admit
CASE("admit: the truth table");
// Two slots: controller, spare. Nothing else exists, and the function has no
// transport parameter to consult -- listener-independence by construction.
CHECK(admitConnection(false, 0, 2) == Admit::Controller); // empty board
CHECK(admitConnection(false, 1, 2) == Admit::Controller); // spare only: still room
CHECK(admitConnection(true, 1, 2) == Admit::Spare); // controller: one spare
CHECK(admitConnection(true, 2, 2) == Admit::Refused); // the third connection
CHECK(admitConnection(false, 2, 2) == Admit::Refused); // cap checked BEFORE slot
CHECK(admitConnection(true, 0, 2) == Admit::Spare); // count says spare live...
// ...but not busy: spare
CASE("admit: cap sizes");
CHECK(admitConnection(false, 0, 1) == Admit::Controller);
CHECK(admitConnection(true, 1, 1) == Admit::Refused); // a 1-slot board: no spare
CHECK(admitConnection(true, 1, 3) == Admit::Spare);
CHECK(admitConnection(true, 2, 3) == Admit::Spare);
CHECK(admitConnection(true, 3, 3) == Admit::Refused);
// The ceiling this project ships is two, and the RAM comment in config.h is written
// against that number: change one, the other has to be re-derived, so pin it here.
CHECK_EQ(kr::kTlsMaxSessions, 2);
// ------------------------------------------------------------------- mapIo
CASE("mapIo: read/write/handshake verdicts");
CHECK(mapIo(1) == Io::Bytes);
CHECK(mapIo(4096) == Io::Bytes); // a full chunk
CHECK(mapIo(7) == Io::Bytes); // a SHORT write is still Bytes --
// the caller keeps framing state
CHECK(mapIo(0) == Io::Closed); // mbed TLS 3.6: 0 = read end closed
CHECK(mapIo(kMbedWantRead) == Io::Want);
CHECK(mapIo(kMbedWantWrite) == Io::Want);
CHECK(mapIo(kMbedPeerCloseNotify) == Io::Closed); // acknowledged end
CHECK(mapIo(-0x0050) == Io::Error); // NET_CONN_RESET
CHECK(mapIo(-0x2700) == Io::Error); // SSL fatal class
CHECK(mapIo(-0x7B00) == Io::Error); // NEW_SESSION_TICKET: client-side
// only; unmapped lands fatal, never
// silently "keep going"
CHECK(mapIo(-1) == Io::Error);
// The numbers themselves. If the shipped core ever renumbers these, the
// static_asserts in tls_session.cpp stop the FIRMWARE build; these stop this suite,
// so "the table drifted from the library" can never be a green run anywhere.
CHECK_EQ(kMbedWantRead, -0x6900);
CHECK_EQ(kMbedWantWrite, -0x6880);
CHECK_EQ(kMbedPeerCloseNotify, -0x7880);
CASE("mapIo: Want never reads as Closed and vice versa");
// The distinction IS the non-blocking contract: a WANT treated as Closed drops a
// working controller every time WiFi is merely slow.
CHECK(mapIo(kMbedWantRead) != Io::Closed);
CHECK(mapIo(0) != Io::Want);
CHECK(mapIo(kMbedWantWrite) != Io::Error);
// ------------------------------------------------------------------- alert
CASE("fatal alert record");
uint8_t alert[16];
CHECK_EQ(renderFatalAlert(alert, sizeof(alert)), (size_t)kFatalAlertLen);
CHECK_EQ(alert[0], 0x15); // record type 21: alert
CHECK_EQ(alert[1], 0x03); // version major 3
CHECK_EQ(alert[2], 0x01); // version minor 1 (TLS 1.1+ field value, per record)
CHECK_EQ(alert[3], 0x00); // length, high byte
CHECK_EQ(alert[4], 0x02); // length, low byte: two bytes of payload
CHECK_EQ(alert[5], 0x02); // alert level: fatal
CHECK_EQ(alert[6], 0x28); // description 40 == 0x28: handshake_failure, RFC 5246
// §7.2.2 -- written here in decimal ON PURPOSE: the byte
// that was first drafted as "0x40 because forty" was the
// whole reason this table got its own test.
CASE("fatal alert: refuses rather than truncates");
CHECK_EQ(renderFatalAlert(alert, kFatalAlertLen - 1), 0);
CHECK_EQ(renderFatalAlert(nullptr, 16), 0);
uint8_t tight[kFatalAlertLen];
memset(tight, 0, sizeof(tight));
CHECK_EQ(renderFatalAlert(tight, kFatalAlertLen), kFatalAlertLen);
CHECK_EQ(tight[0], 0x15);
size_t n = 7;
const uint8_t head[] = "HTTP/1.1 200 OK\r\n\r\n";
// ------------------------------------------------------------------ TxRing
CASE("tx ring: whole frame or nothing");
uint8_t store[64];
TxRing ring(store, sizeof(store));
CHECK_EQ(ring.count(), 0);
CHECK_EQ(ring.capacity(), sizeof(store));
const char frame[] = "0123456789";
CHECK(ring.push(reinterpret_cast<const uint8_t *>(frame), 11));
CHECK_EQ(ring.count(), 11);
// Fill to exactly capacity minus 1... then a frame of 2 must bounce whole, with the
// ring byte-identical to before the refusal.
uint8_t pad[53] = {0};
CHECK(ring.push(pad, sizeof(pad)));
CHECK_EQ(ring.count(), 64u);
const char last[] = "ab";
CHECK(!ring.push(reinterpret_cast<const uint8_t *>(last), 2));
CHECK_EQ(ring.count(), 64u);
CHECK(memcmp(store, frame, 11) == 0); // refused push wrote nothing anywhere
CHECK(!ring.push(reinterpret_cast<const uint8_t *>(last), 1));
CHECK_EQ(ring.count(), 64u);
CASE("tx ring: push bigger than capacity always refused");
ring.reset();
uint8_t big[65] = {0};
CHECK(!ring.push(big, sizeof(big)));
CHECK_EQ(ring.count(), 0);
CHECK(ring.push(big, sizeof(store))); // exactly full is the boundary and passes
CASE("tx ring: head span, wrap, advance");
ring.reset();
uint8_t filler[60] = {0};
CHECK(ring.push(filler, sizeof(filler)));
ring.advance(55); // head sits 9 bytes from the end
CHECK_EQ(ring.count(), 5u);
CHECK(ring.push(pad, 10)); // fills 4 more to the end, wraps for 6
CHECK_EQ(ring.count(), 15u);
CHECK(ring.head(64, &n) == store + 55); // the span runs to the wrap: 5 old + 4 new,
CHECK_EQ(n, 9); // contiguous -- 9, not 5: the ring packs
ring.advance(9); // after the head, not after the old bytes
CHECK(ring.head(64, &n) == store); // ...and the rest continues at index 0
CHECK_EQ(n, 6);
ring.advance(6);
CHECK_EQ(ring.count(), 0);
CHECK(ring.head(64, &n) == nullptr);
CHECK_EQ(n, 0);
CASE("tx ring: head cap bounds the write (one record per chunk)");
ring.reset();
uint8_t bulk[64] = {7};
CHECK(ring.push(bulk, sizeof(bulk)));
CHECK(ring.head(3, &n) == store);
CHECK_EQ(n, 3);
// The resume rule: the same head after a refused (advance-less) write yields the
// identical bytes -- which is what makes a WANT_WRITE retry legal. kConnWriteChunk is
// the cap kr::Conn hands here, for either transport.
CHECK(ring.head(kr::kConnWriteChunk, &n) == store);
CHECK_EQ(n, 64u);
CASE("tx ring: default-constructed holds nothing and refuses everything");
// kr::Conn is bound after it is constructed, so an unseated ring has to exist safely:
// no modulo by zero, no head, nothing accepted. The alternative is a crash on the first
// tick after boot, before any socket has arrived.
TxRing bare;
CHECK_EQ(bare.count(), 0);
CHECK(bare.head(64, &n) == nullptr);
CHECK_EQ(n, 0);
CHECK(!bare.push(reinterpret_cast<const uint8_t *>(head), 4));
bare.advance(4); // must not wrap the head or the count
CHECK_EQ(bare.count(), 0);
CHECK_EQ(bare.head(64, &n), nullptr);
// Capacity 0 is coerced to 1 rather than kept: every index in this class is a modulo,
// and a ring that cannot exist is a crash on the first tick, before any socket arrives.
// The one byte it can hold is never delivered anywhere, because nothing is ever asked
// of an unseated Conn.
TxRing zero(store, 0);
CHECK_EQ(zero.capacity(), 1u);
CHECK(zero.push(reinterpret_cast<const uint8_t *>(head), 1));
CHECK(!zero.push(reinterpret_cast<const uint8_t *>(head), 2));
zero.reset();
CHECK(zero.head(64, &n) == nullptr);
CHECK_EQ(n, 0u);
CASE("tx ring: advance clamps to count; reset empties");
ring.reset();
CHECK(ring.push(bulk, 8));
ring.advance(99);
CHECK_EQ(ring.count(), 0);
CHECK(ring.push(bulk, 8));
ring.reset();
CHECK_EQ(ring.count(), 0);
CHECK(ring.push(bulk, 4)); // usable after reset, head back at 0
CHECK(ring.head(64, &n) == store);
CHECK_EQ(n, 4);
CASE("tx ring: zero-length push is a no-op success");
ring.reset();
CHECK(ring.push(reinterpret_cast<const uint8_t *>(frame), 0));
CHECK_EQ(ring.count(), 0);
CHECK(!ring.push(nullptr, 4)); // nothing to copy: refused, never guessed
CASE("tx ring: reseat moves it onto new storage and empties it");
// Conn re-binds a slot between sockets; a stale byte left behind from the previous
// occupant would be delivered to the new one.
uint8_t other[32] = {0};
ring.reset();
CHECK(ring.push(bulk, 8));
CHECK_EQ(ring.count(), 8u);
ring.reseat(other, sizeof(other));
CHECK_EQ(ring.capacity(), sizeof(other));
CHECK_EQ(ring.count(), 0);
CHECK(ring.head(64, &n) == nullptr);
CHECK(ring.push(bulk, 5));
CHECK_EQ(ring.count(), 5u);
CHECK(ring.head(64, &n) == other); // the bytes went to the NEW buffer
CHECK_EQ(store[0], bulk[0]); // and the old one still holds what it was given
ring.reseat(other, 0); // capacity 0 through reseat is the same safe 1
CHECK_EQ(ring.capacity(), 1u);
CASE("config: the constants this layer promises");
// kConnTxCap must hold the largest single frame the board writes: a status frame plus
// its WebSocket header. If statusJson ever outgrows this arithmetic, the queue would
// start dropping status frames -- visible, but this pin makes it a test failure first.
// (Task 04 renamed it from kTlsTxRingCap: it is now the queue of both transports.)
CHECK(kr::kConnTxCap >= kr::kStatusJsonCap + 16);
CHECK(kr::kConnWriteChunk < 16384); // one record per write: no library fragmentation
return report("test_tls_session");
}