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
+8 -5
View File
@@ -4,10 +4,12 @@
# tests/run_tests.sh build and run every suite
# tests/run_tests.sh clean remove the build directory
#
# The modules under test (src/keystate.h, src/proto.h, src/text.h, src/mouse.h,
# src/output.h, src/ws_codec.h, src/sha1.h) have no Arduino or ESP-IDF dependency by
# design, so the exact code that runs on the board is compiled and exercised here before
# anything is flashed.
# The modules under test (src/keystate.h, src/proto.h, src/status.h, src/text.h,
# src/mouse.h, src/output.h, src/ws_codec.h, src/sha1.h, src/tls_pem.h, and the pure
# policy halves of src/tls_session.h and src/conn.h) have no Arduino or
# ESP-IDF
# dependency by design, so the exact code that runs on the board is compiled and
# exercised here before anything is flashed.
#
# A C++ compiler is not always installed on a workstation, so the runner picks one:
# $CXX if you set it, it wins
@@ -33,7 +35,8 @@ images=(
"docker.io/library/gcc:14"
)
suites=(test_keystate test_proto test_text test_mouse test_output test_ws_codec)
suites=(test_keystate test_proto test_text test_mouse test_output test_ws_codec
test_tls_pem test_tls_session test_conn)
build_and_run() {
local cxx="$1"
+367
View File
@@ -0,0 +1,367 @@
/**
* tests/test_conn.cpp -- the pure half of src/conn.h, walked on the host. No Arduino, no
* WiFiClient, no socket: exactly like tls_pem.h and the policy half of tls_session.h, the
* header keeps its deciding logic free of library includes so the identical code runs here
* under -fsanitize=address,undefined. `kr::Conn` itself hides behind ESP_PLATFORM and is
* proven the other way (both firmware environments link, plus the device rows in
* .agents/HW_VERIFICATION.md: page byte-exact over TLS, :80 redirecting, plaintext /ws
* refused with 426).
*
* What each check pins, and the failure it is defending:
*
* renderHttpResponseHead -- one header formatter for every route, port and transport.
* snprintf truncation used to become `headLen = 0`, which became "the
* board answered nothing". Here: the exact bytes, the Content-Length
* agreeing with the body, an extra header landing in the right place,
* and the widest legal response PROVABLY fitting kHttpResponseHeadCap.
* queryCarriesToken -- the :80 rule "a URL carrying the passphrase is never redirected".
* The boundary matters: a check that matched the substring would turn
* /status?mytoken=x into a 426, and one that missed `&token=` would
* redirect the real thing and leak the passphrase a second time.
* plaintextRoute -- the whole :80 answer table, including that the -D KR_TLS=0
* rollback answers everything (the rollback must stay buildable and
* must stay today's server).
* redirectAuthority -- an attacker-controlled `Host:` becoming a `Location:` a browser
* navigates to. `http://keyrelay.local@evil.com/` is the shape that
* makes this a real open redirect, not a theoretical one.
* renderRedirectTarget -- the request path is client text too, and a CR in it would
* inject headers into the board's own response.
* FlashSend -- the page's send cursor. An off-by-one here is a page that arrives
* a byte short and parses as garbage; a next() that ignores the budget
* is one loop() iteration spending an unbounded time writing a socket.
*
* Two defects in this new code were found by the SANITISED run of this file, not by its
* assertions, and both are pinned by the case that triggered them:
* - queryCarriesToken() memcmp()ed its 6-byte key against the tail of any shorter query,
* reading past the end of `"a=1"` (global-buffer-overflow);
* - dottedQuadPrivate() checked the octet count when a dot arrived instead of before each
* digit, so `"192.168.1.1.1"` wrote octets[4] (stack-buffer-overflow).
* Neither changes an answer on a healthy input, and neither is visible on the board until
* it isn't. That is the argument for `SANITIZE=1` being part of the gate rather than an
* option (AGENTS.md: the sanitiser run found five of the ten bugs the README documents).
*/
#include <string.h>
#include "test.h"
#include "conn.h"
using kr::FlashSend;
using kr::kConnTxCap;
using kr::kConnViewTxCap;
using kr::kConnWriteChunk;
using kr::kHttpResponseHeadCap;
using kr::kPageBytesPerTick;
using kr::kPageChunkBytes;
using kr::kStatusJsonCap;
using kr::plaintextRoute;
using kr::PlainRoute;
using kr::queryCarriesToken;
using kr::redirectAuthority;
using kr::renderHttpResponseHead;
using kr::renderRedirectTarget;
namespace {
// The served page's length, in bytes. Cross-checked against the real raw strings by
// tests/test_relayctl.py ("the page the transport sends"). The page carries one
// 19-byte #if KR_TLS build marker and both spellings are the same length, so this
// number is build-independent -- a fact test_relayctl pins, not a hope.
constexpr size_t kServedPageBytes = 51626;
/** The pump's shape, without a socket: hand out chunks, accept what a peer takes. */
struct DrainLog {
size_t pieces = 0;
size_t bytes = 0;
size_t maxPiece = 0;
};
} // namespace
int main() {
char head[kHttpResponseHeadCap];
// ------------------------------------------------------ the response head
CASE("head: the exact bytes of a plain 200");
const size_t n = renderHttpResponseHead(head, sizeof(head), "200 OK",
"text/plain; charset=utf-8", 5);
CHECK_STR(head,
"HTTP/1.1 200 OK\r\n"
"Content-Type: text/plain; charset=utf-8\r\n"
"Content-Length: 5\r\n"
"Cache-Control: no-store\r\n"
"X-Content-Type-Options: nosniff\r\n"
"Connection: close\r\n\r\n");
CHECK_EQ(n, strlen(head));
CHECK(n > 0 && n < kHttpResponseHeadCap);
CASE("head: the extra header sits before Connection");
CHECK(renderHttpResponseHead(head, sizeof(head), "301 Moved Permanently",
"text/html; charset=utf-8", 0,
"Location: https://keyrelay.local/\r\n") > 0);
CHECK(strstr(head, "Location: https://keyrelay.local/\r\nConnection: close") != nullptr);
CASE("head: 426 carries the Upgrade field RFC 4266 asks for");
CHECK(renderHttpResponseHead(head, sizeof(head), "426 Upgrade Required",
"text/plain; charset=utf-8", 40,
"Upgrade: TLS/1.2, HTTP/1.1\r\n") > 0);
CHECK(strstr(head, "Upgrade: TLS/1.2, HTTP/1.1\r\n") != nullptr);
CHECK(strstr(head, "Content-Length: 40\r\n") != nullptr);
CASE("head: too small a buffer is refused, never truncated");
char tiny[16] = "AAAAAAAAAAAAAAA";
CHECK_EQ(renderHttpResponseHead(tiny, sizeof(tiny), "200 OK", "text/plain", 1), 0);
CHECK_EQ(tiny[0], 'A'); // nothing written at all
CHECK_EQ(renderHttpResponseHead(nullptr, 64, "200 OK", "text/plain", 1), 0);
// The formatter's contract is the SHIPPED cap, so a caller passing anything else gets a
// refusal rather than a head built to a size nobody sized.
char almost[kHttpResponseHeadCap];
CHECK_EQ(renderHttpResponseHead(almost, kHttpResponseHeadCap - 1, "200 OK", "text/plain",
1), 0);
CASE("head: the widest legal response fits, with margin to spare");
// The widest authority redirectAuthority() will actually accept is a dotted quad
// (kCertCommonName is longer than any IP, and nothing else passes the name test); the
// widest path it carries over is /index.html. So this is not a hypothetical worst case,
// it is reachable -- and it must fit anyway, with room left for the next header.
char authority[80];
CHECK(redirectAuthority("192.168.123.234", authority, sizeof(authority)));
char loc[128];
const size_t locLen = renderRedirectTarget("192.168.123.234", "/index.html", loc,
sizeof(loc));
CHECK(locLen > 0);
char extra[160];
const int extraLen = snprintf(extra, sizeof(extra), "Location: %s\r\n", loc);
CHECK(extraLen > 0);
const size_t wide = renderHttpResponseHead(head, sizeof(head), "301 Moved Permanently",
"text/plain; charset=utf-8", 128, extra);
CHECK(wide > 0);
CHECK(wide < kHttpResponseHeadCap);
CHECK(kHttpResponseHeadCap - wide >= 32); // room for one more header, not slack
// -------------------------------------------------- the token-in-URL test
CASE("query: token= is found where a key can start");
CHECK(queryCarriesToken("token=secret"));
CHECK(queryCarriesToken("a=1&token=secret"));
CHECK(queryCarriesToken("a=1&b=2&token=x"));
CHECK(queryCarriesToken("token=")); // an empty token is still a token carried
CASE("query: look-alikes are not token-carrying");
CHECK(!queryCarriesToken(""));
CHECK(!queryCarriesToken(nullptr));
CHECK(!queryCarriesToken("mytoken=secret")); // not a key boundary
CHECK(!queryCarriesToken("a=mytoken"));
CHECK(!queryCarriesToken("status?token")); // no '='
CHECK(!queryCarriesToken("xtoken=secret"));
CHECK(!queryCarriesToken("a=1token=secret")); // '=' is not a key boundary
CASE("query: every short prefix is scanned without reading past the string");
// ASan caught the first version doing exactly this: comparing a 6-byte key against the
// tail of "a=1" reads four bytes that are not there. Nothing about the answer changes;
// what changes is whether the board walks off the end of a buffer on a router's URL.
static const char *const kShort[] = {"t", "to", "tok", "toke", "token", "=", "&", "a=1",
"a=1&", "a=1&b", "x&token=", "&&token=x"};
for (const char *const shortq : kShort) {
const bool want = std::strcmp(shortq, "x&token=") == 0 ||
std::strcmp(shortq, "&&token=x") == 0;
CHECK_EQ(queryCarriesToken(shortq) ? 1 : 0, want ? 1 : 0);
}
CASE("route: the :80 table, in one place, for both builds");
// tlsBuild == false is the -D KR_TLS=0 rollback: every shape is served, exactly as
// before phase 02. That is the rollback criterion, and it is a row of this table.
CHECK(plaintextRoute(false, "token=x", false) == PlainRoute::Serve);
CHECK(plaintextRoute(true, nullptr, false) == PlainRoute::Serve);
CHECK(plaintextRoute(false, nullptr, false) == PlainRoute::Serve);
// tlsBuild == true: nothing on :80 is served; an upgrade and a token both get 426; the
// rest get the redirect. 426 before redirect, for the token, always.
CHECK(plaintextRoute(false, nullptr, true) == PlainRoute::Redirect);
CHECK(plaintextRoute(false, "a=1", true) == PlainRoute::Redirect);
CHECK(plaintextRoute(false, "token=secret", true) == PlainRoute::UpgradeRequired);
CHECK(plaintextRoute(true, nullptr, true) == PlainRoute::UpgradeRequired);
// A /ws upgrade carrying a token is still an upgrade: the transport answer wins, and it
// is the more fundamental one ("this port does not speak that at all").
CHECK(plaintextRoute(true, "token=secret", true) == PlainRoute::UpgradeRequired);
// -------------------------------------------------- the redirect authority
CASE("authority: the names this board is reached at survive");
char auth[80];
CHECK(redirectAuthority("keyrelay.local", auth, sizeof(auth)));
CHECK_STR(auth, "keyrelay.local");
CHECK(redirectAuthority("KEYRELAY.LOCAL", auth, sizeof(auth)));
CHECK_STR(auth, "keyrelay.local");
CHECK(redirectAuthority("keyrelay", auth, sizeof(auth)));
CHECK_STR(auth, "keyrelay");
// "IP if the name fails" is in the README; a redirect that threw the IP away would
// break the documented fallback path.
CHECK(redirectAuthority("192.168.4.1", auth, sizeof(auth)));
CHECK_STR(auth, "192.168.4.1");
CHECK(redirectAuthority("10.2.0.22:80", auth, sizeof(auth)));
CHECK_STR(auth, "10.2.0.22"); // :port stripped, the scheme's own is implied
CHECK(redirectAuthority("172.20.1.9", auth, sizeof(auth)));
CHECK_STR(auth, "172.20.1.9");
CASE("authority: everything else falls back to the certificate's name");
// Each of these would otherwise put attacker-chosen text into a Location a browser
// follows. The @ one is the shape of a real open-redirect URL.
static const char *const kRejected[] = {
"evil.com", "keyrelay.local@evil.com", "keyrelay.local.evil.com", "evil.local",
"1.2.3.4", "8.8.8.8:443", "keyrela", "keyrelay.too", "-bad", "bad-", ".local", "",
"a b", "a\re", "::1", "192.168.1", "192.168.1.1.1", "300.1.1.1", "172.32.0.1",
"host:80:90",
};
for (const char *const bad : kRejected) {
CHECK(redirectAuthority(bad, auth, sizeof(auth)));
CHECK_STR(auth, kr::kCertCommonName);
}
CHECK(redirectAuthority(nullptr, auth, sizeof(auth)));
CHECK_STR(auth, kr::kCertCommonName);
char longish[300];
memset(longish, 'a', sizeof(longish) - 1);
longish[sizeof(longish) - 1] = '\0';
CHECK(redirectAuthority(longish, auth, sizeof(auth)));
CHECK_STR(auth, kr::kCertCommonName); // truncated, then refused: never echoed
CHECK(!redirectAuthority("keyrelay.local", auth, 4)); // cannot even hold the fallback
CASE("redirect target: only the two content paths travel");
CHECK(renderRedirectTarget("keyrelay.local", "/", loc, sizeof(loc)) > 0);
CHECK_STR(loc, "https://keyrelay.local/");
CHECK(renderRedirectTarget("keyrelay.local", "/index.html", loc, sizeof(loc)) > 0);
CHECK_STR(loc, "https://keyrelay.local/index.html");
CHECK(renderRedirectTarget("keyrelay.local", "/nope", loc, sizeof(loc)) > 0);
CHECK_STR(loc, "https://keyrelay.local/"); // 404 awaits on the encrypted port
// Header injection: the request target is client bytes, and Location is a header.
CHECK(renderRedirectTarget("keyrelay.local", "/a\r\nX-Evil: 1", loc, sizeof(loc)) > 0);
CHECK_STR(loc, "https://keyrelay.local/");
CHECK(renderRedirectTarget("keyrelay.local", "/a\nX-Evil: 1", loc, sizeof(loc)) > 0);
CHECK_STR(loc, "https://keyrelay.local/");
CHECK(renderRedirectTarget("keyrelay.local", "", loc, sizeof(loc)) > 0);
CHECK_STR(loc, "https://keyrelay.local/");
CHECK(renderRedirectTarget("keyrelay.local", nullptr, loc, sizeof(loc)) > 0);
CHECK_STR(loc, "https://keyrelay.local/");
CHECK_EQ(renderRedirectTarget("keyrelay.local", "/", loc, 4), 0); // would not fit
CHECK_EQ(renderRedirectTarget("keyrelay.local", "/", nullptr, 16), 0);
// and no redirect body ever contains a query
CHECK(renderRedirectTarget("192.168.4.1", "/index.html", loc, sizeof(loc)) > 0);
CHECK(strchr(loc, '?') == nullptr);
// ------------------------------------------------------ the flash send
CASE("flash send: empty and null are inert");
FlashSend fs;
CHECK(!fs.armed());
CHECK(fs.done());
CHECK_EQ(fs.sent(), 0);
CHECK_EQ(fs.total(), 0);
CHECK_EQ(fs.next(100, 100), 0);
fs.start(nullptr, 10);
CHECK(!fs.armed());
CHECK_EQ(fs.total(), 0);
fs.start("abc", 0); // a zero-length body is nothing to send
CHECK(!fs.armed());
CHECK(fs.done());
CASE("flash send: one chunk at a time, in order, clamped");
static const char page[] = "0123456789ABCDEFGHIJ"; // 20 bytes
fs.start(page, sizeof(page) - 1);
CHECK(fs.armed());
CHECK(!fs.done());
CHECK_EQ(fs.total(), 20);
CHECK_EQ(fs.remaining(), 20);
CHECK_EQ(fs.next(8, 100), 8); // chunk cap
CHECK_EQ(fs.next(8, 5), 5); // budget cap, whichever is smaller
CHECK_EQ(fs.next(8, 0), 0); // no budget: nothing to hand out
CHECK_EQ(fs.next(0, 100), 0);
char out[32];
memset(out, '.', sizeof(out));
fs.copyTo(out, 8);
CHECK(memcmp(out, "01234567", 8) == 0);
fs.copyTo(out, 999); // clamped to what is left, no overrun
CHECK_EQ(out[20], '.');
fs.advance(8);
CHECK_EQ(fs.sent(), 8);
CHECK_EQ(fs.remaining(), 12);
fs.advance(0); // the retry path: identical bytes again
CHECK_EQ(fs.sent(), 8);
fs.copyTo(out, 4);
CHECK(memcmp(out, "89AB", 4) == 0);
CASE("flash send: completing keeps the totals readable");
DrainLog log;
fs.start(page, sizeof(page) - 1);
while (fs.armed()) {
const size_t take = fs.next(8, 100);
if (take == 0) break;
fs.copyTo(out, take);
// every byte of the piece is the page's, at the cursor: the whole claim of the pump
for (size_t i = 0; i < take; i++)
CHECK(out[i] == page[log.bytes + i]);
log.bytes += take;
log.pieces++;
if (take > log.maxPiece) log.maxPiece = take;
fs.advance(take);
}
CHECK(fs.done());
CHECK(!fs.armed());
CHECK_EQ(log.bytes, 20);
CHECK_EQ(log.pieces, 3); // 8 + 8 + 4
CHECK_EQ(log.maxPiece, 8);
CHECK_EQ(fs.sent(), 20); // sent()/total() still describe the send
CHECK_EQ(fs.remaining(), 0);
fs.advance(5); // past the end: clamped, no wrap
CHECK_EQ(fs.sent(), 20);
CASE("flash send: the real page, on the real budget");
// 51 626 bytes (the count .agents/validate.sh prints) through kPageChunkBytes per write
// and kPageBytesPerTick per tick: the exact numbers the device check compares against.
{
// A stand-in of the served page's size (51 626 B, the `page bytes:` line the gate
// prints). tests/test_relayctl.py pins this constant against the raw string in
// src/web_page.h, so if the page changes and this number does not, a check fails --
// the same "test the table" rule as the mouse bits and the evdev codes.
static const char big[kServedPageBytes] = {0};
FlashSend p;
p.start(big, sizeof(big));
size_t ticks = 0, total = 0, worstTick = 0, worstPiece = 0;
while (p.armed() && ticks < 1000) {
size_t thisTick = 0;
while (p.armed()) {
const size_t take = p.next(kPageChunkBytes, kPageBytesPerTick - thisTick);
if (take == 0) break;
CHECK(take <= kPageChunkBytes);
thisTick += take;
CHECK(thisTick <= kPageBytesPerTick);
total += take;
if (take > worstPiece) worstPiece = take;
p.advance(take);
}
CHECK(thisTick <= kPageBytesPerTick);
if (thisTick > worstTick) worstTick = thisTick;
ticks++;
}
CHECK(p.done());
CHECK_EQ(total, sizeof(big));
CHECK(worstPiece <= kPageChunkBytes);
CHECK(worstTick <= kPageBytesPerTick);
// 51 626 / 1460 = 36 chunks; / 5840 per tick = 9 ticks. If either number changes in
// config.h this line moves, and the report has to move with it.
CHECK_EQ((sizeof(big) + kPageBytesPerTick - 1) / kPageBytesPerTick, 9);
CHECK_EQ((sizeof(big) + kPageChunkBytes - 1) / kPageChunkBytes, 36);
}
CASE("config: the sizing this layer promises");
{
// One transport write is never bigger than the transport's own record cap, and the
// per-tick budget is a whole number of chunks -- otherwise the last write of a tick
// would be a runt and the arithmetic above would have a special case in it.
CHECK(kPageChunkBytes <= kConnWriteChunk);
CHECK_EQ(kPageBytesPerTick % kPageChunkBytes, 0);
CHECK(kPageBytesPerTick >= kPageChunkBytes);
// The spare slot must be able to hold the widest response it can be asked to send:
// a head and its body are queued as a PAIR, so the pair has to fit.
CHECK_EQ(kConnViewTxCap, kHttpResponseHeadCap + kStatusJsonCap);
CHECK(kConnTxCap >= kHttpResponseHeadCap + kStatusJsonCap);
CHECK(kConnTxCap >= kStatusJsonCap + 16); // a whole status frame, as before
}
return report("test_conn");
}
+154 -10
View File
@@ -76,12 +76,11 @@ const byId = id => {
return registry.get(id);
};
const sentFrames = [];
const idleStatus = () => x.onFrame('S{"armed":1,"usb":1,"typing":0}');
const busyStatus = n => x.onFrame(`S{"armed":1,"usb":1,"typing":${n}}`);
class FakeWebSocket {
constructor() {
constructor(url) {
this.url = url; // what the page asked to connect to
this.readyState = 1; // OPEN
FakeWebSocket.instances.push(this);
setTimeout(() => this.onopen && this.onopen(), 0);
@@ -112,7 +111,11 @@ const doc = {
const sandbox = {
document: doc,
window: { addEventListener: winListeners.add },
location: { protocol: "http:", host: "keyrelay.local" },
// A TLS board never hands its page an http: origin (:80 answers a redirect), so
// the origin this suite boots on is https:, like the real first load. The
// connect-path section switches it deliberately to walk both the refusal and
// the -D KR_TLS=0 rollback.
location: { protocol: "https:", host: "keyrelay.local" },
sessionStorage: { getItem: () => null, setItem() {} },
WebSocket: FakeWebSocket,
setInterval: () => 0, // the reconnect/keepalive loop stays shut in a test
@@ -125,7 +128,9 @@ const sandbox = {
const expose = `
;globalThis.__x = {
log, logEl, untypeableChars, hexify, describePress, inPageField, pasteText,
sendPaste, refreshSendBox, setArmed, abortTyping, onFrame, connect,
sendPaste, refreshSendBox, setArmed, abortTyping, onFrame, connect, boot,
servedInClear,
get tlsBuild() { return KR_TLS_BUILD; }, set tlsBuild(v) { KR_TLS_BUILD = v; },
TEXT_FRAME_MAX, TEXT_JOB_MAX, TEXT_PACE_MS,
takeMouse, dropMouse, flushMouse, mouseFrame, paintMouse, refreshMouseUi,
MOUSE_DELTA_MAX, MOUSE_FRAME_MS, MOUSE_MAX_FRAMES, MOUSE_WHEEL_PX,
@@ -140,19 +145,52 @@ const expose = `
const headerPath = path.join(__dirname, "..", "src", "web_page.h");
const headerText = fs.readFileSync(headerPath, "utf8");
const from = headerText.indexOf("<script>");
const to = headerText.indexOf("<\/script>", from);
// The page is a chain of R"HTML( ... )HTML" raw-string chunks joined by exactly one
// #if KR_TLS build marker (task 05: the page has to know its own build before any
// socket exists). Assemble the bytes one build serves, the same assembly
// tests/test_relayctl.py and .agents/validate.sh perform -- three readers of one
// layout, each failing if the layout drifts.
function servedPage(text, tls) {
const chunks = [...text.matchAll(/R"HTML\(([\s\S]*?)\)HTML"/g)].map(m => m[1]);
const marks = [...text.matchAll(
/#if KR_TLS\n\s*"([^"]*)"\n#else\n\s*"([^"]*)"\n#endif/g)];
if (chunks.length === 0 || marks.length !== chunks.length - 1) {
console.error("web_page.h: raw-string chunks and #if KR_TLS markers no longer " +
"join up; fix this harness rather than shipping a guessed page");
process.exit(1);
}
let out = "";
chunks.forEach((c, i) => {
out += c;
if (i < marks.length) out += tls ? marks[i][1] : marks[i][2];
});
return out;
}
const served = servedPage(headerText, true); // the TLS build, by default
const rollbackPage = servedPage(headerText, false);
const from = served.indexOf("<script>");
const to = served.indexOf("<\/script>", from);
if (from < 0 || to < 0) {
console.error("no <script> block in " + headerPath);
process.exit(1);
}
const pageScript = headerText.slice(from + "<script>".length, to);
const pageScript = served.slice(from + "<script>".length, to);
const context = vm.createContext(sandbox);
vm.runInContext(pageScript + expose, context);
const x = sandbox.__x;
const logEl = x.logEl;
// Declared after FakeWebSocket's class body only because the class closes over
// it: send() runs long after this line has, and a hoisted `let` would be a
// temporal-dead-zone trap waiting for a socket that opens during page load.
const sentFrames = [];
const idleStatus = () => x.onFrame('S{"armed":1,"usb":1,"typing":0}');
const busyStatus = n => x.onFrame(`S{"armed":1,"usb":1,"typing":${n}}`);
// ---------------------------------------------------------------------------
let checks = 0, failures = 0;
@@ -482,8 +520,114 @@ checkEq(!!x.inPageField({ target: null }), false, "a missing target does not thr
x.mCaptured = false;
x.setArmed(false, "harness: done");
console.log("the connect path");
// The board serves the page over https:// (and, in a -D KR_TLS=0 build, over http://).
// The socket scheme follows the page's own origin: an https: page that opened ws://
// would be blocked by the browser as mixed content, and the failure would look exactly
// like a dead board. The other direction is the guard task 05 added: a TLS build's
// page on an http: origin is bytes nobody legitimate served (a TLS board answers :80
// with a redirect), so it refuses -- and the rollback build, whose marker is 0,
// keeps its ws:// because that is the documented way back.
checkEq(Buffer.byteLength(served), Buffer.byteLength(rollbackPage),
"both builds' pages are the same byte count -- the marker branches are " +
"equal length, so every pinned page count is one number");
byId("token").value = "hunter2"; // connect() refuses without one
sandbox.location.protocol = "https:";
FakeWebSocket.instances.length = 0;
x.connect();
await wait(5);
let inst = FakeWebSocket.instances[FakeWebSocket.instances.length - 1];
check(inst !== undefined, "connect() opened a socket on an https: origin");
checkEq(inst.url, "wss://keyrelay.local/ws",
"an https: origin connects over wss:// -- the encrypted controller channel");
// The passphrase travels in the first frame, never in a URL: a query string lands in
// proxy logs, browser history and the Referer of anything the page later loads.
check(!/token|pass/i.test(inst.url), `the connect URL carries no credential (${inst.url})`);
// An http: origin on the TLS build: no socket, no frame, and the reason is said.
FakeWebSocket.instances.length = 0;
sentFrames.length = 0;
sandbox.location.protocol = "http:";
x.connect();
await wait(5);
checkEq(FakeWebSocket.instances.length, 0,
"a clear-loaded TLS page opens no socket -- it does not fall back to plaintext");
checkEq(sentFrames.length, 0,
"and sends not one frame -- above all not the T<passphrase>");
check(String(logEl.lastElementChild && logEl.lastElementChild.textContent)
.includes("this page arrived in the clear; reload over https"),
"and it says why, in the words the operator can act on");
// boot() on the same origin: the passphrase box itself comes disabled, with the
// banner up -- refusing the socket while still accepting the passphrase would be
// half a guard.
byId("token").disabled = false;
byId("connect").disabled = false;
byId("clearWarn").hidden = true;
x.boot();
checkEq(byId("token").disabled, true, "a clear-loaded page will not take a passphrase");
checkEq(byId("connect").disabled, true, "and its connect button is inert");
checkEq(byId("clearWarn").hidden, false, "and the banner explains the refusal");
checkEq(FakeWebSocket.instances.length, 0, "boot() on http: connects to nothing");
// The rollback build (-D KR_TLS=0) keeps today's behaviour on the same origin:
// ws:// is its documented way back, opted into by the build flag itself.
x.tlsBuild = 0;
byId("token").disabled = false;
byId("connect").disabled = false;
byId("clearWarn").hidden = true;
FakeWebSocket.instances.length = 0;
x.connect();
await wait(5);
inst = FakeWebSocket.instances[FakeWebSocket.instances.length - 1];
check(inst !== undefined, "the KR_TLS=0 page still connects on an http: origin");
if (inst) checkEq(inst.url, "ws://keyrelay.local/ws",
"the rollback page speaks plaintext ws:// because that is the rollback");
x.tlsBuild = 1;
sandbox.location.protocol = "https:"; // as the shim started
// The plaintext spelling exists exactly once in the served bytes -- the fallback
// the rollback build reaches. A TLS board never hands a browser an http: origin
// (port 80 answers a redirect and a 426, pinned in tests/test_relayctl.py and
// walked in tests/test_conn.cpp), so one occurrence is the whole rollback and
// two would be a second, ungated path.
checkEq(served.split("ws://").length - 1, 1,
"the served page contains exactly one plaintext ws:// -- the rollback's path");
checkEq(served.split("wss://").length - 1, 1, "and exactly one wss://, its counterpart");
checkEq(served.split("new WebSocket(").length - 1, 1,
"one socket is opened in the whole page, and it opens the url just built");
check(served.includes("var KR_TLS_BUILD=1;"),
"the TLS build's assembled page carries its marker");
check(rollbackPage.includes("var KR_TLS_BUILD=0;"),
"and the assembled rollback page carries the other one");
console.log("the channel pill and the certificate line");
// Both render from the status frame only -- the page has no other way to know,
// and an operator compares these bytes against `cert` on the serial console.
x.onFrame('S{"armed":1,"usb":1,"typing":0,"tls":1,"fp":"0123abcd456789ef"}');
checkEq(byId("pTls").textContent, "tls", "a TLS board's pill says tls");
checkEq(byId("pTls").className, "pill on", "and it is the calm green state");
checkEq(byId("certFp").hidden, false, "the certificate line is up");
check(byId("certFp").textContent.includes("0123abcd456789ef"),
"the line renders exactly the fp the status frame carried");
check(byId("certFp").textContent.includes("cert"),
"and names where to confirm it (the serial `cert` command)");
x.onFrame('S{"armed":1,"usb":1,"typing":0,"tls":0,"fp":"0123abcd456789ef"}');
checkEq(byId("pTls").textContent, "plaintext", "a board without TLS says plaintext");
check(byId("pTls").className.includes("bad"),
"and says it in the alarming colour -- unencrypted is the state to notice");
checkEq(byId("certFp").hidden, true, "with tls:0 no fingerprint line renders, ...");
checkEq(byId("certFp").textContent, "",
"... even when the frame carried one anyway (the page does not launder it)");
x.onFrame('S{"armed":1,"usb":1,"typing":0,"tls":1}');
checkEq(byId("certFp").hidden, true,
"and a tls:1 frame with no fp renders no line either -- nothing is invented");
x.onFrame('S{"armed":1,"usb":1,"typing":0,"tls":1,"fp":"00aa11bb22cc33dd"}');
checkEq(byId("certFp").textContent.includes("00aa11bb22cc33dd"), true,
"a new status re-renders the new fingerprint");
console.log("element ids");
const markup = fs.readFileSync(headerPath, "utf8");
const markup = served;
const present = new Set();
for (const m of markup.matchAll(/id="([A-Za-z][A-Za-z0-9]*)"/g)) present.add(m[1]);
check(present.size > 15, `the markup's ids were parsed (${present.size})`);
+122
View File
@@ -5,10 +5,12 @@
#include "keystate.h"
#include "proto.h"
#include "status.h"
#include "test.h"
using kr::proto::Cmd;
using kr::proto::Message;
using kr::StatusSnapshot; // the status frame, rendered by status.h
namespace {
@@ -535,5 +537,125 @@ int main() {
CHECK(out.find("typer.cancel()") != std::string::npos);
}
CASE("the status frame: the TLS identity renders, and the frame fits its cap");
{
// statusJson() lives in status.h so that this can run at all. It used to sit behind
// #include <WiFi.h>, which left the number that decides whether the controller's whole
// picture of the board arrives intact -- kr::kStatusJsonCap -- unmeasured by anything on the
// host. A frame that does not fit renders as *nothing* (the function returns 0 and the
// caller sends nothing), so an added field would have looked like a dropped socket or a
// stalled page, three commits away from the cause.
StatusSnapshot s;
char json[kr::kStatusJsonCap];
const char *fp = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
// A default snapshot: no TLS, and no label for an identity that is not there.
size_t n = statusJson(s, json, sizeof(json));
CHECK(n > 0);
CHECK(n < kr::kStatusJsonCap);
CHECK(strstr(json, "\"tls\":0") != nullptr);
CHECK(strstr(json, "\"fp\":\"\"") != nullptr);
CHECK_EQ(kr::kStatusFingerprintBytes, 8);
CHECK_EQ(kr::kStatusFingerprintHex, 16);
// The identity renders as `"tls":1` plus a `fp` of exactly 16 hex characters -- the
// first kr::kStatusFingerprintBytes of the digest, not the whole 64: this field is a label to
// eyeball against `cert`, and the full value is the console's job.
s.tls = true;
s.fp = fp;
n = statusJson(s, json, sizeof(json));
CHECK(n > 0);
CHECK(strstr(json, "\"tls\":1") != nullptr);
CHECK(strstr(json, "\"fp\":\"ba7816bf8f01cfea\"") != nullptr);
CHECK(strstr(json, "0de5dae2223") == nullptr);
// Upper case hex is as renderable as lower case: the board prints lower, but a value
// that arrived upper case must survive the boundary unchanged rather than be cut to "".
s.fp = "BA7816BF8F01CFEA0123456789abcdef";
n = statusJson(s, json, sizeof(json));
CHECK(strstr(json, "\"fp\":\"BA7816BF8F01CFEA\"") != nullptr);
// tls:0 wins over a filled-in label. A page that drew a fingerprint next to "no TLS" is
// drawing an picture of security the board does not have.
s.tls = false;
n = statusJson(s, json, sizeof(json));
CHECK(strstr(json, "\"tls\":0") != nullptr);
CHECK(strstr(json, "\"fp\":\"\"") != nullptr);
// The JSON boundary: a stored value that had gone non-hex is cut where it stops being
// hex instead of pasted in, because a quote inside a field is a field forgery -- the same
// reason the SSID below is sanitised.
s.tls = true;
s.fp = "01234567" "\"armed\":0,\"x\":\"";
n = statusJson(s, json, sizeof(json));
CHECK(n > 0);
CHECK(strstr(json, "\"fp\":\"01234567\"") != nullptr);
int armedFields = 0;
for (const char *p = json; (p = strstr(p, "\"armed\"")) != nullptr; p++) armedFields++;
CHECK_EQ(armedFields, 1);
// An SSID is somebody else's bytes on that same boundary: quotes, backslashes, control
// characters and 0x7F never reach the frame, and an over-long one is cut at the buffer
// rather than at the frame. (Behaviour inherited from control_server.cpp; it is now
// testable, so it is now tested.)
StatusSnapshot hostile;
hostile.ssid = "a\"b\\c" "\x01" "d" "\x7f" "e";
n = statusJson(hostile, json, sizeof(json));
CHECK(n > 0);
CHECK(strstr(json, "\"ssid\":\"abcde\"") != nullptr);
CHECK(strchr(json, '\\') == nullptr);
hostile.ssid = "0123456789012345678901234567890123456789012345678901234567890123456789";
n = statusJson(hostile, json, sizeof(json));
CHECK(n > 0);
// 39 characters: jsonSafe() fills cap-1 of the 40-byte buffer it is given.
CHECK(strstr(json, "\"ssid\":\"012345678901234567890123456789012345678\"") != nullptr);
// The longest frame the board can legally put on the wire: a 32-character SSID (the
// 802.11 limit, and staSsid is 33 bytes), a 15-character dotted quad, UINT32_MAX in each
// of the five counters, UINT16_MAX in `typing`, every LED bit and every mouse button, and
// the whole 16-hex `fp`. This is the arithmetic config.h quotes -- measured here rather
// than believed, with the headroom asserted as at least one more field's worth so the
// next field is a decision and not an overflow nobody looked for.
StatusSnapshot worst;
worst.version = kr::kFirmwareVersion;
worst.uptimeS = 0xFFFFFFFFu;
worst.usbHost = true;
worst.armed = true;
worst.netUp = true;
worst.net = "down"; // the longest of the three net modes
worst.ssid = "01234567890123456789012345678901";
worst.ip = "255.255.255.255";
worst.keysSent = 0xFFFFFFFFu;
worst.chordsSent = 0xFFFFFFFFu;
worst.dropped = 0xFFFFFFFFu;
worst.typedChars = 0xFFFFFFFFu;
worst.typing = 0xFFFF;
worst.rollover = true;
worst.mouse = true;
worst.mouseButtons = kr::kMouseButtonMask;
worst.mouseFrames = 0xFFFFFFFFu;
worst.ledState = 7; // caps/num/scroll: the three bits a report carries
worst.defaultToken = true;
worst.apForced = true;
worst.tls = true;
worst.fp = fp;
char big[kr::kStatusJsonCap + 16];
const size_t longest = statusJson(worst, big, sizeof(big));
CHECK(longest > 0);
CHECK(longest <= kr::kStatusJsonCap);
CHECK(kr::kStatusJsonCap - longest >= 32);
CHECK_EQ(strlen(big), longest);
CHECK(big[0] == '{');
CHECK(big[longest - 1] == '}');
CHECK(strchr(big, '\n') == nullptr); // one frame, one line
CHECK(strstr(big, "\"tls\":1") != nullptr);
CHECK(strstr(big, "\"fp\":\"ba7816bf8f01cfea\"") != nullptr);
// And the cap means it: one byte short renders nothing at all, which is the difference
// between a status that is missing and a status that is wrong.
CHECK_EQ(statusJson(worst, big, longest), 0);
CHECK_EQ(statusJson(worst, big, longest + 1), longest);
}
return report("proto");
}
+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
+993
View File
@@ -0,0 +1,993 @@
// tests for the pure half of the TLS identity: src/tls_pem.h
//
// This suite includes tls_pem.h and nothing that drags in a crypto library. If it ever
// includes src/tls.h the host build breaks -- which is the point: that is the rule that
// keeps the identical code running here and on the board (AGENTS.md "Editing rules").
//
// What is under test is the part a handshake cannot diagnose. A certificate that fails
// to parse says "malformed"; it does not say "your PEM had 63 characters on line two",
// "your fingerprint was truncated to 63 hex digits" or "your SAN wrote the IP address as
// text". Each check below is named after the failure it would catch.
#include <cstring>
#include <string>
#include <vector>
#include "sha1.h" // base64Encode: a second, independent base64 to cross-check against
#include "test.h"
#include "tls_pem.h"
using namespace kr;
using namespace kr::tls; // the module under test; kr alone does not reach into it
namespace {
// A pattern that cannot appear in any output, so "writes nothing" is an observation
// rather than an assumption.
constexpr char kSentinel = char(0x5A);
void prime(char *buf, size_t cap) {
memset(buf, kSentinel, cap);
buf[cap - 1] = '\0';
}
bool untouched(const char *buf, size_t len) {
for (size_t i = 0; i < len; i++) {
if (buf[i] != kSentinel) return false;
}
return true;
}
std::string hexOf(const uint8_t *p, size_t len) {
static const char kHex[] = "0123456789abcdef";
std::string s;
for (size_t i = 0; i < len; i++) {
s += kHex[p[i] >> 4];
s += kHex[p[i] & 0xF];
}
return s;
}
// The lines strictly between the BEGIN line and the END line. An armour block with no
// body has none, and a helper that reported the END marker as a body line would quietly
// turn the empty-input case into a passing test of nothing.
std::vector<std::string> bodyLines(const char *pem) {
std::vector<std::string> all;
const std::string s(pem);
size_t start = 0;
while (true) {
const size_t q = s.find('\n', start);
if (q == std::string::npos) {
all.push_back(s.substr(start));
break;
}
all.push_back(s.substr(start, q - start));
start = q + 1;
}
if (!all.empty() && all.back().empty()) all.pop_back(); // the final terminator
if (all.size() <= 2) return std::vector<std::string>();
return std::vector<std::string>(all.begin() + 1, all.end() - 1);
}
// A DER-shaped blob: every byte value walks somewhere and it is not all zeros, so a
// base64 bug that only shows up on particular bit patterns cannot hide here.
void fillBlob(uint8_t *buf, size_t len) {
for (size_t i = 0; i < len; i++) {
buf[i] = static_cast<uint8_t>((i * 37 + 11) & 0xFF);
}
}
// The sizes that straddle the 64-character armour line: 48 DER bytes are exactly 64
// base64 characters, so 45/46/47 land on the last partial quantum of a full line and
// 49/50/51 spill onto the next one. 704 is the worst case the generator can hand over.
const size_t kLineBoundarySizes[] = {0, 1, 2, 3, 4, 45, 46, 47, 48, 49,
50, 51, 94, 95, 96, 97, 142, 143, 144, 145,
146, 299, 300, 301, 400, 704};
std::string toCrlf(const char *src) {
std::string out;
for (const char *p = src; *p; p++) {
if (*p == '\n') out += "\r\n";
out += *p;
}
return out;
}
} // namespace
int main() {
char pem[2048];
uint8_t der[1024];
uint8_t back[1024];
CASE("wrapPem: one known vector, byte for byte");
{
// RFC 4648's own example, "foobar" -> "Zm9vYmFy". If the alphabet table is off by
// one character anywhere, this is the check that says so.
const uint8_t in[] = {'f', 'o', 'o', 'b', 'a', 'r'};
prime(pem, sizeof(pem));
const size_t n = wrapPem("CERTIFICATE", in, sizeof(in), pem, sizeof(pem));
CHECK_STR(pem, "-----BEGIN CERTIFICATE-----\nZm9vYmFy\n-----END CERTIFICATE-----\n");
CHECK_EQ(n, strlen(pem));
CHECK_EQ(n, 63); // 28 head + 9 body (8 characters and their newline) + 26 tail
// An empty input is the two armour lines and nothing between them.
prime(pem, sizeof(pem));
const size_t z = wrapPem("CERTIFICATE", nullptr, 0, pem, sizeof(pem));
CHECK_STR(pem, "-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----\n");
CHECK_EQ(z, strlen(pem));
// The label a private key is armoured under is a wire format, not decoration.
prime(pem, sizeof(pem));
wrapPem(kPemKeyLabel, in, sizeof(in), pem, sizeof(pem));
CHECK_STR(pem,
"-----BEGIN EC PRIVATE KEY-----\nZm9vYmFy\n-----END EC PRIVATE KEY-----\n");
}
CASE("wrapPem: 64 characters a line, boundary by boundary");
{
for (size_t s = 0; s < sizeof(kLineBoundarySizes) / sizeof(kLineBoundarySizes[0]); s++) {
const size_t len = kLineBoundarySizes[s];
fillBlob(der, len);
prime(pem, sizeof(pem));
const size_t n = wrapPem("CERTIFICATE", der, len, pem, sizeof(pem));
CHECK(n > 0);
CHECK_EQ(n, strlen(pem)); // no NUL in the middle, nothing after the end
// Every body line is exactly 64 characters except possibly the last, and a body
// exists exactly when there are bytes to carry.
const std::vector<std::string> lines = bodyLines(pem);
const size_t b64Len = ((len + 2) / 3) * 4;
const size_t wantLines = b64Len == 0 ? 0 : (b64Len + 63) / 64;
CHECK_EQ(lines.size(), wantLines);
for (size_t i = 0; i < lines.size(); i++) {
const bool lastLine = i + 1 == lines.size();
CHECK(lines[i].size() <= 64);
CHECK(lines[i].size() >= 1);
if (!lastLine) CHECK_EQ(lines[i].size(), 64);
}
// Cross-checked against sha1.h's base64, which the WebSocket handshake already
// trusts: two implementations written for different purposes that agree at every
// length cannot both be wrong about the alphabet.
char ref[1400];
const size_t refLen = base64Encode(der, len, ref, sizeof(ref));
CHECK_EQ(refLen, b64Len);
std::string stripped;
for (const std::string &l : lines) stripped += l;
CHECK(stripped == std::string(ref, refLen));
// And back again, unchanged.
size_t got = 0;
CHECK(pemFind("CERTIFICATE", pem, back, sizeof(back), &got));
CHECK_EQ(got, len);
CHECK(len == 0 || memcmp(der, back, len) == 0);
}
}
CASE("wrapPem: a padded final group on a line of its own still gets its newline");
{
// 1, 2 and everything 1 or 2 mod 48: the base64 body ends with a padded group that
// starts a fresh line, because 48 DER bytes fill exactly 64 characters. A writer that
// counts only whole groups leaves that line unterminated, and the END marker glues
// itself onto the body -- pemFind still decodes it, so the round trip passes and the
// block is nonetheless not what any other tool on earth writes. This was a real bug
// here; the independent-base64 cross-check above is what caught it.
const size_t paddedGroupSizes[] = {1, 2, 49, 50, 97, 98, 145, 146};
for (size_t s = 0; s < sizeof(paddedGroupSizes) / sizeof(paddedGroupSizes[0]); s++) {
const size_t len = paddedGroupSizes[s];
fillBlob(der, len);
prime(pem, sizeof(pem));
const size_t n = wrapPem("CERTIFICATE", der, len, pem, sizeof(pem));
CHECK(n > 0);
const char *endMark = strstr(pem, "-----END");
CHECK(endMark != nullptr);
CHECK(endMark > pem + 1);
CHECK_EQ(endMark[-1], '\n'); // the body's last line is terminated
CHECK_EQ(endMark[-2], '='); // ...and it really was the padded group
// The body is exactly ceil(b64/64) lines, the last one 4 characters long.
const std::vector<std::string> lines = bodyLines(pem);
const size_t b64Len = ((len + 2) / 3) * 4;
CHECK_EQ(lines.size(), (b64Len + 63) / 64);
CHECK_EQ(lines.back().size(), 4);
CHECK_EQ(lines.back()[3], '=');
}
}
CASE("wrapPem: refuses a buffer one byte short, and writes nothing at all");
{
fillBlob(der, 100);
prime(pem, sizeof(pem));
const size_t exact = wrapPem("CERTIFICATE", der, 100, pem, sizeof(pem));
CHECK_EQ(exact, strlen(pem));
// A buffer the size of the text: the NUL is the byte that does not fit, and a
// caller who counted characters and not the terminator is the bug this catches.
prime(pem, sizeof(pem));
CHECK_EQ(wrapPem("CERTIFICATE", der, 100, pem, exact), 0);
CHECK(untouched(pem, exact));
CHECK_EQ(wrapPem("CERTIFICATE", der, 100, pem, exact + 1), exact);
// Same for the empty-body case, which has a size of its own.
prime(pem, sizeof(pem));
const size_t emptyNeed = wrapPem("CERTIFICATE", nullptr, 0, pem, sizeof(pem));
prime(pem, sizeof(pem));
CHECK_EQ(wrapPem("CERTIFICATE", nullptr, 0, pem, emptyNeed), 0);
CHECK(untouched(pem, emptyNeed));
CHECK_EQ(wrapPem("CERTIFICATE", nullptr, 0, pem, emptyNeed + 1), emptyNeed);
}
CASE("wrapPem: refuses input it cannot name");
{
fillBlob(der, 8);
prime(pem, sizeof(pem));
CHECK_EQ(wrapPem(nullptr, der, 8, pem, sizeof(pem)), 0);
CHECK_EQ(wrapPem("", der, 8, pem, sizeof(pem)), 0);
CHECK_EQ(wrapPem("CERTIFICATE", nullptr, 8, pem, sizeof(pem)), 0); // bytes, no bytes
CHECK_EQ(wrapPem("CERTIFICATE", der, 8, nullptr, sizeof(pem)), 0);
// A label over the bound is a caller bug; exactly at the bound still works, so the
// refusal is about the bound and not about caution.
char longLabel[kPemLabelMax + 2];
memset(longLabel, 'X', sizeof(longLabel) - 1);
longLabel[sizeof(longLabel) - 1] = '\0';
CHECK_EQ(wrapPem(longLabel, der, 8, pem, sizeof(pem)), 0);
longLabel[kPemLabelMax - 1] = '\0';
CHECK(wrapPem(longLabel, der, 8, pem, sizeof(pem)) > 0);
}
CASE("pemFind: reads back what a real tool wrote");
{
fillBlob(der, 47);
prime(pem, sizeof(pem));
CHECK(wrapPem("CERTIFICATE", der, 47, pem, sizeof(pem)) > 0);
size_t got = 0;
// LF, as wrapPem writes it.
CHECK(pemFind("CERTIFICATE", pem, back, sizeof(back), &got));
CHECK_EQ(got, 47);
CHECK(memcmp(der, back, 47) == 0);
// CRLF: whatever passed through a Windows editor, and the shape a copy-pasted
// certificate usually arrives in.
const std::string crlf = toCrlf(pem);
CHECK(pemFind("CERTIFICATE", crlf.c_str(), back, sizeof(back), &got));
CHECK_EQ(got, 47);
CHECK(memcmp(der, back, 47) == 0);
// No trailing newline after the END marker.
std::string trimmed(pem, strlen(pem) - 1);
CHECK(pemFind("CERTIFICATE", trimmed.c_str(), back, sizeof(back), &got));
CHECK_EQ(got, 47);
// Spaces and tabs inside the body: "AAAA" is three zero bytes, "BBBB" is 04 10 41.
const std::string spaced =
"-----BEGIN CERTIFICATE-----\nAAAA \t\nBBBB\n-----END CERTIFICATE-----\n";
CHECK(pemFind("CERTIFICATE", spaced.c_str(), back, sizeof(back), &got));
CHECK_EQ(got, 6);
CHECK(memcmp(back, "\x00\x00\x00\x04\x10\x41", 6) == 0);
// An empty body is zero bytes -- not a failure, and not garbage.
CHECK(pemFind("CERTIFICATE", "-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----\n",
back, sizeof(back), &got));
CHECK_EQ(got, 0);
// Two blocks in one haystack: the first of that label wins.
fillBlob(der, 3);
char second[256];
wrapPem("CERTIFICATE", der, 3, second, sizeof(second));
const std::string both = std::string(pem) + second;
CHECK(pemFind("CERTIFICATE", both.c_str(), back, sizeof(back), &got));
CHECK_EQ(got, 47);
// ...and a block stored under another label is not this one.
CHECK(!pemFind("EC PRIVATE KEY", both.c_str(), back, sizeof(back), &got));
// The label matches exactly: a request for CERTIFICATE must not pick up a
// CERTIFICATE REQUEST, or a key would be found where a certificate belongs.
CHECK(!pemFind("CERTIFICATE",
"-----BEGIN CERTIFICATE REQUEST-----\nAAAA\n-----END CERTIFICATE REQUEST-----\n",
back, sizeof(back), &got));
}
CASE("pemFind: rejects a malformed block instead of decoding almost the right thing");
{
size_t got = 99;
const char *good = "-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n";
CHECK(!pemFind("CERTIFICATE", "", back, sizeof(back), &got));
CHECK(!pemFind("CERTIFICATE", "not a pem at all", back, sizeof(back), &got));
// BEGIN without END: a record torn by power loss in the middle of the write.
CHECK(!pemFind("CERTIFICATE", "-----BEGIN CERTIFICATE-----\nAAAA\n", back, sizeof(back), &got));
CHECK(!pemFind("CERTIFICATE", "-----END CERTIFICATE-----\n", back, sizeof(back), &got));
// One character torn out of the body, so the length is no longer a multiple of four.
CHECK(!pemFind("CERTIFICATE", "-----BEGIN CERTIFICATE-----\nAAA\n-----END CERTIFICATE-----\n",
back, sizeof(back), &got));
// A character from the armour inside the body -- what a block pasted inside itself
// looks like, and what a buffer that held two writes looks like after a realloc.
CHECK(!pemFind("CERTIFICATE", "-----BEGIN CERTIFICATE-----\nAA-A\n-----END CERTIFICATE-----\n",
back, sizeof(back), &got));
CHECK(!pemFind("CERTIFICATE", "-----BEGIN CERTIFICATE-----\nAA<>\n-----END CERTIFICATE-----\n",
back, sizeof(back), &got));
// Padding is only legal in the last two positions of the last group.
CHECK(!pemFind("CERTIFICATE", "-----BEGIN CERTIFICATE-----\nA=AA\n-----END CERTIFICATE-----\n",
back, sizeof(back), &got));
CHECK(!pemFind("CERTIFICATE", "-----BEGIN CERTIFICATE-----\n=AAA\n-----END CERTIFICATE-----\n",
back, sizeof(back), &got));
CHECK(!pemFind("CERTIFICATE",
"-----BEGIN CERTIFICATE-----\nAAAA=A\n-----END CERTIFICATE-----\n",
back, sizeof(back), &got));
CHECK(!pemFind("CERTIFICATE", "-----BEGIN CERTIFICATE-----\nAAAA====AAAA\n"
"-----END CERTIFICATE-----\n",
back, sizeof(back), &got));
// Legal padding, for contrast: "QQ==" is one byte, "QUI=" is two.
// "QQ==" is "A" and "QUI=" is "AB" -- RFC 4648's own padding examples.
CHECK(pemFind("CERTIFICATE", "-----BEGIN CERTIFICATE-----\nQQ==\n-----END CERTIFICATE-----\n",
back, sizeof(back), &got));
CHECK_EQ(got, 1);
CHECK_EQ(back[0], 'A');
CHECK(pemFind("CERTIFICATE", "-----BEGIN CERTIFICATE-----\nQUI=\n-----END CERTIFICATE-----\n",
back, sizeof(back), &got));
CHECK_EQ(got, 2);
CHECK_EQ(back[0], 'A');
CHECK_EQ(back[1], 'B');
// Does not fit: a refusal with nothing written, never a truncated decode.
prime(reinterpret_cast<char *>(back), 8);
CHECK(!pemFind("CERTIFICATE", good, back, 2, &got));
CHECK(untouched(reinterpret_cast<const char *>(back), 7));
CHECK(pemFind("CERTIFICATE", good, back, 3, &got));
CHECK_EQ(got, 3);
// Degenerate arguments.
CHECK(!pemFind(nullptr, good, back, sizeof(back), &got));
CHECK(!pemFind("", good, back, sizeof(back), &got));
CHECK(!pemFind("CERTIFICATE", nullptr, back, sizeof(back), &got));
CHECK(!pemFind("CERTIFICATE", good, nullptr, sizeof(back), &got));
char longLabel[kPemLabelMax + 2];
memset(longLabel, 'X', sizeof(longLabel) - 1);
longLabel[sizeof(longLabel) - 1] = '\0';
CHECK(!pemFind(longLabel, good, back, sizeof(back), &got));
// A caller that does not want the length still gets the right bytes.
CHECK(pemFind("CERTIFICATE", good, back, sizeof(back)));
CHECK_EQ(back[0], 0);
}
CASE("fingerprintHex: the published SHA-256 of \"abc\"");
{
// FIPS 180-4. The digest arrives as bytes -- the board hashes with the platform
// library -- so what is under test is the rendering the operator reads, which is
// where a fingerprint goes wrong in practice: one nibble dropped, and the pinned
// value never matches while every handshake succeeds.
const uint8_t abc[32] = {
0xba, 0x78, 0x16, 0xbf, 0x8f, 0x01, 0xcf, 0xea, 0x41, 0x41, 0x40,
0xde, 0x5d, 0xae, 0x22, 0x23, 0xb0, 0x03, 0x61, 0xa3, 0x96, 0x17,
0x7a, 0x9c, 0xb4, 0x10, 0xff, 0x61, 0xf2, 0x00, 0x15, 0xad};
char hex[128];
prime(hex, sizeof(hex));
CHECK(fingerprintHex(abc, sizeof(abc), hex, 65));
CHECK_STR(hex, "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
CHECK_EQ(strlen(hex), 64);
CHECK_EQ(kFingerprintCap, 65);
CHECK_EQ(fingerprintHexCap(32), 65);
// Exactly one byte short: no output, not a 63-character fingerprint.
prime(hex, sizeof(hex));
CHECK(!fingerprintHex(abc, sizeof(abc), hex, 64));
CHECK(untouched(hex, 64));
// Lowercase always: the operator copies this out of `openssl x509 -fingerprint`.
const uint8_t high[1] = {0xFF};
CHECK(fingerprintHex(high, 1, hex, sizeof(hex)));
CHECK_STR(hex, "ff");
// A zero-length digest is an empty string, and it still needs room for the NUL.
CHECK(fingerprintHex(high, 0, hex, 1));
CHECK_STR(hex, "");
prime(hex, sizeof(hex));
CHECK(!fingerprintHex(high, 0, hex, 0));
CHECK(!fingerprintHex(nullptr, 32, hex, sizeof(hex)));
CHECK(!fingerprintHex(abc, 32, nullptr, sizeof(hex)));
}
CASE("the names in the certificate are the names in config.h");
{
// kCertCommonName is the string the operator types into a browser. If it ever stops
// being kHostname + ".local", the certificate and the mDNS name diverge and every
// connection starts with a warning -- so this pins the config value, not just its
// formatting.
const std::string derived = std::string(kHostname) + ".local";
CHECK_STR(kCertCommonName, derived.c_str());
char subject[64];
prime(subject, sizeof(subject));
const size_t n = subjectLine(subject, sizeof(subject));
CHECK_STR(subject, "CN=keyrelay.local");
CHECK_EQ(n, strlen(subject));
CHECK_EQ(n, 17);
// No comma: the certificate writer parses this as a comma-separated name list.
CHECK(strchr(subject, ',') == nullptr);
prime(subject, sizeof(subject));
CHECK_EQ(subjectLine(subject, n), 0);
CHECK(untouched(subject, n));
CHECK_EQ(subjectLine(nullptr, sizeof(subject)), 0);
char san[128];
prime(san, sizeof(san));
const size_t s = sanList(san, sizeof(san));
CHECK_STR(san, "DNS:keyrelay.local, DNS:keyrelay, IP:192.168.4.1");
CHECK_EQ(s, strlen(san));
// Every name the README tells the operator to expect, taken from config.h rather
// than restated here.
CHECK(strstr(san, kCertCommonName) != nullptr);
CHECK(strstr(san, kHostname) != nullptr);
CHECK(strstr(san, kApGw) != nullptr);
// The subject and the list agree on the common name, because a human is shown one
// and the browser checks the other.
CHECK(strstr(san, kCertCommonName) == san + strlen("DNS:"));
prime(san, sizeof(san));
CHECK_EQ(sanList(san, s), 0);
CHECK(untouched(san, s));
CHECK_EQ(sanList(san, 0), 0);
CHECK_EQ(sanList(nullptr, sizeof(san)), 0);
size_t count = 0;
const SanEntry *entries = sanEntries(&count);
CHECK_EQ(count, 3);
CHECK(entries[0].type == SanType::Dns);
CHECK_STR(entries[0].value, kCertCommonName);
CHECK(entries[1].type == SanType::Dns);
CHECK_STR(entries[1].value, kHostname);
CHECK(entries[2].type == SanType::Ip);
CHECK_STR(entries[2].value, kApGw);
CHECK(sanEntries(nullptr) == entries); // one table, whoever asks
CHECK_STR(sanTypeName(SanType::Dns), "DNS:");
CHECK_STR(sanTypeName(SanType::Ip), "IP:");
}
CASE("kPemMax holds the largest block the generator can hand it");
{
// config.h sizes kPemMax from a measured certificate of ~430 DER bytes armoured to
// ~603; the DER scratch in tls.cpp is 704 bytes, so 704 is the worst case anything
// can ask to be armoured. wrapPem refuses rather than truncates, so if kPemMax ever
// falls below that, the board fails to get an identity at boot -- loudly, which is
// the point, but only if a test says so first.
fillBlob(der, 704);
const size_t n = wrapPem(kPemCertLabel, der, 704, pem, kPemMax);
CHECK(n > 0);
CHECK(n == strlen(pem));
CHECK(n < kPemMax);
CHECK_EQ(kPemMax, 1024); // the number in config.h, pinned so the headroom is a fact
}
CASE("sanExtensionDer: the GeneralNames a verifier actually parses");
{
uint8_t out[64];
const size_t n = sanExtensionDer(out, sizeof(out));
const std::string got = hexOf(out, n);
// SEQUENCE(32) { [2] "keyrelay.local", [2] "keyrelay", [7] C0 A8 04 01 }
CHECK_EQ(n, 34);
CHECK_STR(got.c_str(),
"3020820e6b657972656c61792e6c6f63616c82086b657972656c61798704c0a80401");
// Cross-checked, not asserted: a certificate signed with
// openssl req -x509 -key <P-256 key> -subj /CN=keyrelay.local
// -sha256 -addext "subjectAltName=DNS:keyrelay.local,DNS:keyrelay,IP:192.168.4.1"
// contains these exact 34 bytes, and `openssl asn1parse -inform DER` reads them back
// as SEQUENCE { cont[2] 14, cont[2] 8, cont[7] 4 }. This module's encoder and
// OpenSSL's agree byte for byte, which is what turns "the browser will accept it"
// into a measured claim instead of a hope.
CHECK_EQ(sanExtensionDer(out, n - 1), 0);
CHECK_EQ(sanExtensionDer(out, 1), 0);
CHECK_EQ(sanExtensionDer(nullptr, sizeof(out)), 0);
CHECK_EQ(sanExtensionDer(out, n), n); // an exact fit is a fit
}
CASE("kSanOidTlv: the extension's label, as DER content bytes and not as dotted text");
{
// RFC 5280 4.2.1.6: subjectAltName is 2.5.29.17, whose OBJECT IDENTIFIER is
// 06 03 55 1D 11 -- first arc 40*2 + 5 = 0x55, then 29 and 17. These five bytes are
// what makes the 34 above a SAN; without them the certificate carries a name list no
// client will read, and nothing on the board says so.
CHECK_EQ(kSanOidTlvLen, 5);
CHECK_EQ(kSanOidTlv[0], 0x06); // UNIVERSAL 6, OBJECT IDENTIFIER
CHECK_EQ(kSanOidTlv[1], 0x03); // three content bytes
CHECK_EQ(kSanOidTlv[2], 0x55); // 2.5
CHECK_EQ(kSanOidTlv[3], 0x1D); // .29
CHECK_EQ(kSanOidTlv[4], 0x11); // .17
size_t n = 0;
const uint8_t *oid = sanOidDer(&n);
CHECK_EQ(n, 3);
CHECK_EQ(oid[0], 0x55);
CHECK_EQ(oid[1], 0x1D);
CHECK_EQ(oid[2], 0x11);
CHECK_EQ(sanOidDer(nullptr), oid); // the count is optional, the bytes are not
// The failure this pins is the one that actually happened: the dotted string handed to
// the writer as if it were the encoding. Nine ASCII bytes are a legal OID --
// 1.10.46.53.46.50.57.46.49.55, as `openssl x509 -text` printed on a real board -- so
// the library accepts them, the certificate signs, the handshake completes and only the
// hostname check disagrees, in a sentence about the name. No byte of the text form may
// appear in what this module hands over.
for (size_t i = 0; i < n; i++) {
CHECK(oid[i] != '.' && !(oid[i] >= '0' && oid[i] <= '9'));
}
CHECK(!derContains(kSanOidTlv, kSanOidTlvLen, reinterpret_cast<const uint8_t *>("2.5"), 3));
// And the cross-check, the same way the 34 value bytes were: OpenSSL 4.0.3 writes
// 30 29 06 03 55 1d 11 04 22 <the 34 bytes>
// for `-addext "subjectAltName=DNS:keyrelay.local,DNS:keyrelay,IP:192.168.4.1"`, which
// is the sanExtensionFullDer() case below, byte for byte.
}
CASE("ASN.1 lengths: short form, long form, never either by accident");
{
CHECK_EQ(asn1LengthBytes(0), 1);
CHECK_EQ(asn1LengthBytes(127), 1);
CHECK_EQ(asn1LengthBytes(128), 2);
CHECK_EQ(asn1LengthBytes(255), 2);
CHECK_EQ(asn1LengthBytes(256), 3);
CHECK_EQ(asn1LengthBytes(65535), 3);
CHECK_EQ(asn1LengthBytes(65536), 4);
uint8_t len[8];
CHECK_EQ(asn1WriteLength(0, len), 1);
CHECK_EQ(len[0], 0x00);
CHECK_EQ(asn1WriteLength(127, len), 1);
CHECK_EQ(len[0], 0x7F);
CHECK_EQ(asn1WriteLength(128, len), 2);
CHECK_EQ(len[0], 0x81);
CHECK_EQ(len[1], 0x80);
CHECK_EQ(asn1WriteLength(300, len), 3); // 0x82 + two value bytes
CHECK_EQ(len[0], 0x82);
CHECK_EQ(len[1], 0x01);
CHECK_EQ(len[2], 0x2C); // 300 == 0x012C
CHECK_EQ(asn1WriteLength(65536, len), 4);
CHECK_EQ(len[0], 0x83);
CHECK_EQ(len[1], 0x01);
CHECK_EQ(len[2], 0x00);
CHECK_EQ(len[3], 0x00);
}
CASE("ipv4Bytes: a dotted quad or nothing");
{
uint8_t ip[4];
CHECK(ipv4Bytes("192.168.4.1", ip));
CHECK_EQ(ip[0], 192);
CHECK_EQ(ip[1], 168);
CHECK_EQ(ip[2], 4);
CHECK_EQ(ip[3], 1);
CHECK(ipv4Bytes("0.0.0.0", ip));
CHECK(ipv4Bytes("255.255.255.255", ip));
CHECK(!ipv4Bytes("256.1.1.1", ip));
CHECK(!ipv4Bytes("1.2.3.256", ip));
CHECK(!ipv4Bytes("1.2.3", ip)); // an address a resolver would silently expand
CHECK(!ipv4Bytes("1.2.3.", ip));
CHECK(!ipv4Bytes(".1.2.3", ip));
CHECK(!ipv4Bytes("1.2.3.4.5", ip));
CHECK(!ipv4Bytes("1.2.3.4 ", ip));
CHECK(!ipv4Bytes(" 1.2.3.4", ip));
CHECK(!ipv4Bytes("a.b.c.d", ip));
CHECK(!ipv4Bytes("", ip));
CHECK(!ipv4Bytes("1.2.3.999", ip));
CHECK(!ipv4Bytes(nullptr, ip));
CHECK(!ipv4Bytes("1.2.3.4", nullptr));
// The address the certificate actually carries has to parse, or nothing else here
// matters: this is the one value that goes on the wire.
CHECK(ipv4Bytes(kApGw, ip));
}
CASE("build date: __DATE__ parsed with the field widths it really has");
{
int y = 0;
int mo = 0;
int d = 0;
// Single-digit days are space padded to two characters: the classic mis-parse.
CHECK(parseBuildDate("Nov 8 2026", y, mo, d));
CHECK_EQ(y, 2026);
CHECK_EQ(mo, 11);
CHECK_EQ(d, 8);
CHECK(parseBuildDate("Jan 1 1970", y, mo, d));
CHECK_EQ(y, 1970);
CHECK_EQ(mo, 1);
CHECK_EQ(d, 1);
CHECK(parseBuildDate("Dec 31 2049", y, mo, d));
CHECK_EQ(y, 2049);
CHECK_EQ(mo, 12);
CHECK_EQ(d, 31);
CHECK(parseBuildDate("Feb 29 2028", y, mo, d)); // a leap day is a legal build date
CHECK_EQ(d, 29);
CHECK(!parseBuildDate("Nuv 8 2026", y, mo, d)); // month letters transposed
CHECK(!parseBuildDate("Foo 8 2026", y, mo, d));
CHECK(!parseBuildDate("Nov 8 26", y, mo, d)); // two-digit year
CHECK(!parseBuildDate("Nov 8 20261", y, mo, d)); // trailing junk
CHECK(!parseBuildDate("Nov 0 2026", y, mo, d));
CHECK(!parseBuildDate("Nov 32 2026", y, mo, d));
CHECK(!parseBuildDate("Nov", y, mo, d));
CHECK(!parseBuildDate("", y, mo, d));
CHECK(!parseBuildDate(nullptr, y, mo, d));
// The build this binary came from has to parse, or there is no identity at all.
CHECK(parseBuildDate(__DATE__, y, mo, d));
// And the window still has to fit UTCTime (1950..2049). This is a real, dated
// expiry: a build from 2040 onwards cannot issue a ten-year UTCTime certificate and
// would fail at boot with a build-date error. Better a red test in 2039 than a board
// that refuses to come up in 2040.
CHECK(y + kCertValidityYears <= 2049);
CHECK(y >= 2020);
CHECK_EQ(kCertValidityYears, 10);
}
CASE("calendar arithmetic: month ends, year ends, February");
{
CHECK(leapYear(2028));
CHECK(!leapYear(2026));
CHECK(!leapYear(1900));
CHECK(leapYear(2000));
CHECK_EQ(daysInMonth(2028, 2), 29);
CHECK_EQ(daysInMonth(2026, 2), 28);
CHECK_EQ(daysInMonth(2026, 1), 31);
CHECK_EQ(daysInMonth(2026, 4), 30);
CHECK_EQ(daysInMonth(2026, 0), 0);
CHECK_EQ(daysInMonth(2026, 13), 0);
int y, mo, d;
y = 2026; mo = 1; d = 1;
addDays(y, mo, d, -1);
CHECK_EQ(y, 2025); CHECK_EQ(mo, 12); CHECK_EQ(d, 31); // back over a year end
y = 2028; mo = 3; d = 1;
addDays(y, mo, d, -1);
CHECK_EQ(y, 2028); CHECK_EQ(mo, 2); CHECK_EQ(d, 29); // back into a leap day
y = 2026; mo = 12; d = 31;
addDays(y, mo, d, 1);
CHECK_EQ(y, 2027); CHECK_EQ(mo, 1); CHECK_EQ(d, 1);
y = 2026; mo = 1; d = 31;
addDays(y, mo, d, 1);
CHECK_EQ(y, 2026); CHECK_EQ(mo, 2); CHECK_EQ(d, 1);
y = 2026; mo = 6; d = 15;
addDays(y, mo, d, 0);
CHECK_EQ(y, 2026); CHECK_EQ(mo, 6); CHECK_EQ(d, 15);
// Ten years from a leap day lands on the last day that exists, never on a date the
// certificate writer would reject at build time.
y = 2028; mo = 2; d = 29;
addYears(y, mo, d, 1);
CHECK_EQ(y, 2029); CHECK_EQ(mo, 2); CHECK_EQ(d, 28);
y = 2028; mo = 2; d = 29;
addYears(y, mo, d, 4);
CHECK_EQ(y, 2032); CHECK_EQ(mo, 2); CHECK_EQ(d, 29); // 2032 is a leap year
y = 2026; mo = 1; d = 31;
addYears(y, mo, d, kCertValidityYears);
CHECK_EQ(y, 2036); CHECK_EQ(mo, 1); CHECK_EQ(d, 31);
y = 2026; mo = 11; d = 8;
addYears(y, mo, d, 0);
CHECK_EQ(y, 2026); CHECK_EQ(mo, 11); CHECK_EQ(d, 8);
}
CASE("utcTime: the string the certificate writer parses, or a refusal");
{
char t[32];
prime(t, sizeof(t));
CHECK_EQ(utcTime(t, sizeof(t), 2026, 11, 8, 0, 0, 0), 14);
CHECK_STR(t, "20261108000000");
CHECK_EQ(utcTime(t, sizeof(t), 2049, 12, 31, 23, 59, 59), 14);
CHECK_STR(t, "20491231235959");
CHECK_EQ(utcTime(t, sizeof(t), 1950, 1, 1, 0, 0, 0), 14);
CHECK_STR(t, "19500101000000");
CHECK_EQ(utcTime(t, sizeof(t), 2028, 2, 29, 12, 34, 56), 14);
CHECK_STR(t, "20280229123456");
// Exactly enough room, and one byte short of it.
CHECK_EQ(utcTime(t, 15, 2026, 11, 8, 0, 0, 0), 14);
prime(t, sizeof(t));
CHECK_EQ(utcTime(t, 14, 2026, 11, 8, 0, 0, 0), 0);
CHECK(untouched(t, 14));
// Outside UTCTime's window: GeneralizedTime is a different encoding, and quietly
// emitting one is how every handshake starts failing in 2050.
CHECK_EQ(utcTime(t, sizeof(t), 1949, 12, 31, 0, 0, 0), 0);
CHECK_EQ(utcTime(t, sizeof(t), 2050, 1, 1, 0, 0, 0), 0);
// Components out of range, including the date that does not exist.
CHECK_EQ(utcTime(t, sizeof(t), 2026, 0, 1, 0, 0, 0), 0);
CHECK_EQ(utcTime(t, sizeof(t), 2026, 13, 1, 0, 0, 0), 0);
CHECK_EQ(utcTime(t, sizeof(t), 2026, 11, 0, 0, 0, 0), 0);
CHECK_EQ(utcTime(t, sizeof(t), 2026, 11, 31, 0, 0, 0), 0);
CHECK_EQ(utcTime(t, sizeof(t), 2026, 2, 29, 0, 0, 0), 0); // 2026 is not a leap year
CHECK_EQ(utcTime(t, sizeof(t), 2026, 11, 8, 24, 0, 0), 0);
CHECK_EQ(utcTime(t, sizeof(t), 2026, 11, 8, 0, 60, 0), 0);
CHECK_EQ(utcTime(t, sizeof(t), 2026, 11, 8, 0, 0, 61), 0);
CHECK_EQ(utcTime(nullptr, sizeof(t), 2026, 11, 8, 0, 0, 0), 0);
// :60 is the leap-second slot RFC 5280 allows; accepting it is not a bug.
CHECK_EQ(utcTime(t, sizeof(t), 2026, 11, 8, 23, 59, 60), 14);
}
CASE("the boot path's own sequence, with a blob where the key would be");
{
// What task 02 does at boot: produce a certificate DER, armour it, store the text,
// read it back out of storage, hand the bytes to the library. Every step here is the
// real code; only key generation is replaced by a blob, because the host has no
// crypto library and must not gain one.
fillBlob(der, 430);
const size_t n = wrapPem(kPemCertLabel, der, 430, pem, kPemMax);
CHECK(n > 0);
CHECK(n == strlen(pem));
// The NVS record is read back as a C string: the armour must carry no NUL of its own
// and must end with the newline the writer was asked to leave in.
CHECK(pem[n] == '\0');
CHECK(pem[n - 1] == '\n');
uint8_t parsed[1024];
size_t parsedLen = 0;
CHECK(pemFind(kPemCertLabel, pem, parsed, sizeof(parsed), &parsedLen));
CHECK_EQ(parsedLen, 430);
CHECK(memcmp(parsed, der, 430) == 0);
// A record whose tail was lost: refused, not decoded into something shorter.
const std::string torn(pem, n - 30);
CHECK(!pemFind(kPemCertLabel, torn.c_str(), parsed, sizeof(parsed), &parsedLen));
// The private key travels through the same two functions under its own label, and a
// reader looking for the certificate must not find it.
fillBlob(der, 121);
char keyPem[512];
CHECK(wrapPem(kPemKeyLabel, der, 121, keyPem, sizeof(keyPem)) > 0);
CHECK(!pemFind(kPemCertLabel, keyPem, parsed, sizeof(parsed), &parsedLen));
CHECK(pemFind(kPemKeyLabel, keyPem, parsed, sizeof(parsed), &parsedLen));
CHECK_EQ(parsedLen, 121);
CHECK(memcmp(parsed, der, 121) == 0);
}
CASE("fingerprintPrefix: the status frame's label, or a refusal");
{
// The status frame carries the leading bytes of the fingerprint so the page can show the
// same label `cert` prints. Half a stored fingerprint must not turn into a shorter label
// that still looks like a label: the whole job of the value is "same board as before".
const char *fp = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
char out[80]; // a whole 64-character fingerprint plus the NUL
prime(out, sizeof(out));
CHECK(fingerprintPrefix(fp, 16, out, sizeof(out)));
CHECK_STR(out, "ba7816bf8f01cfea");
CHECK(fingerprintPrefix(fp, 64, out, sizeof(out)));
CHECK_STR(out, fp);
// Room is exact -- 16 characters need 17 bytes -- and one byte short writes nothing
// rather than a 15-character label somebody believes.
CHECK(fingerprintPrefix(fp, 16, out, 17));
prime(out, sizeof(out));
CHECK(!fingerprintPrefix(fp, 16, out, 16));
CHECK(untouched(out, 16));
// Not hex, or too short for the prefix asked for: a torn NVS record is a refusal, never
// a prefix of garbage. Upper case is still hex -- refusing it would make a pin somebody
// typed in upper case look like a different board.
CHECK(!fingerprintPrefix("ba78zzzzzzzzzzzzzz", 16, out, sizeof(out)));
CHECK(!fingerprintPrefix("ba78", 16, out, sizeof(out)));
CHECK(!fingerprintPrefix("", 16, out, sizeof(out)));
CHECK(fingerprintPrefix("BA7816BF8F01CFEA", 16, out, sizeof(out)));
CHECK_STR(out, "BA7816BF8F01CFEA");
CHECK(!fingerprintPrefix(nullptr, 16, out, sizeof(out)));
CHECK(!fingerprintPrefix(fp, 16, nullptr, sizeof(out)));
// A zero-length prefix is a legitimate empty string, and still needs room for the NUL.
CHECK(fingerprintPrefix(fp, 0, out, 1));
CHECK_STR(out, "");
}
CASE("fingerprintPairs: two lines the CDC transmit ring cannot swallow");
{
const char *fp = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
char line[128];
prime(line, sizeof(line));
CHECK_EQ(fingerprintPairs(fp, 0, 16, line, sizeof(line)), 47);
CHECK_STR(line, "ba:78:16:bf:8f:01:cf:ea:41:41:40:de:5d:ae:22:23");
CHECK_EQ(fingerprintPairs(fp, 16, 16, line, sizeof(line)), 47);
CHECK_STR(line, "b0:03:61:a3:96:17:7a:9c:b4:10:ff:61:f2:00:15:ad");
// The two lines the console prints are 47 characters each, which with the 8-space indent
// stays under the 128-byte TinyUSB CDC transmit ring. That is the entire reason the print
// is wrapped: an unwrapped 95-character line with the global transmit timeout at 0 is
// dropped mid-value, which is README bug 1 wearing a fingerprint.
CHECK(47 + 8 < 128);
// Nothing is lost on the way: take the colons out of both lines and it is the digest.
char first[64], second[64];
CHECK_EQ(fingerprintPairs(fp, 0, 16, first, sizeof(first)), 47);
CHECK_EQ(fingerprintPairs(fp, 16, 16, second, sizeof(second)), 47);
std::string rejoined;
for (const char *p = first; *p; p++) {
if (*p != ':') rejoined += *p;
}
for (const char *p = second; *p; p++) {
if (*p != ':') rejoined += *p;
}
CHECK_EQ(rejoined.size(), 64);
CHECK_STR(rejoined.c_str(), fp);
// One byte short of the 48 it needs is a refusal that writes nothing; so is a source that
// stops being hex partway, which must not leave half a fingerprint in the buffer.
prime(line, sizeof(line));
CHECK_EQ(fingerprintPairs(fp, 0, 16, line, 47), 0);
CHECK(untouched(line, 47));
std::string bad(fp);
bad[4] = 'z';
prime(line, sizeof(line));
CHECK_EQ(fingerprintPairs(bad.c_str(), 0, 16, line, sizeof(line)), 0);
CHECK(untouched(line, 47));
// An empty range, or one that runs off the end of a 32-byte digest: refused, because
// printing past the digest is how a fingerprint grows characters it never had.
CHECK_EQ(fingerprintPairs(fp, 0, 0, line, sizeof(line)), 0);
CHECK_EQ(fingerprintPairs(fp, 32, 1, line, sizeof(line)), 0);
CHECK_EQ(fingerprintPairs(fp, 31, 2, line, sizeof(line)), 0);
CHECK_EQ(fingerprintPairs(fp, 31, 1, line, sizeof(line)), 2); // the last pair alone
CHECK_STR(line, "ad");
CHECK_EQ(fingerprintPairs(nullptr, 0, 16, line, sizeof(line)), 0);
CHECK_EQ(fingerprintPairs(fp, 0, 16, nullptr, sizeof(line)), 0);
}
CASE("derContains: the certificate decides whether the printed names are true");
{
// `cert` prints the name list from sanEntries(); this is the check that the stored
// certificate really carries those bytes, so the console can say "and the certificate
// agrees" rather than implying it. The needle is the exact extension value, so a name
// changed by one character is a refusal, not a near match.
uint8_t san[kSanDerMax];
const size_t sanLen = sanExtensionDer(san, sizeof(san));
CHECK_EQ(sanLen, 34);
std::vector<uint8_t> cert;
for (size_t i = 0; i < 120; i++) cert.push_back(static_cast<uint8_t>(0xA0 ^ (i & 0x3F)));
cert.insert(cert.end(), san, san + sanLen);
for (size_t i = 0; i < 200; i++) cert.push_back(static_cast<uint8_t>(i * 7));
CHECK(derContains(cert.data(), cert.size(), san, sanLen));
// An empty needle is true of every buffer in existence, so it is a refusal instead: the
// answer has to be about these bytes.
CHECK(!derContains(cert.data(), cert.size(), san, 0));
CHECK(!derContains(cert.data(), 10, san, sanLen));
CHECK(!derContains(nullptr, cert.size(), san, sanLen));
CHECK(!derContains(cert.data(), cert.size(), nullptr, sanLen));
// One byte different in the needle and the certificate no longer carries it...
uint8_t torn[kSanDerMax];
memcpy(torn, san, sanLen);
torn[sanLen - 1] ^= 0x01;
CHECK(!derContains(cert.data(), cert.size(), torn, sanLen));
// ... and one byte different inside the certificate's own SAN, either.
std::vector<uint8_t> other = cert;
other[120 + 1] ^= 0x01; // the first byte of the first DNS name
CHECK(!derContains(other.data(), other.size(), san, sanLen));
// A one-byte haystack is not a special case that only matches at offset 0 by luck.
const uint8_t one = 0x30;
CHECK(derContains(&one, 1, &one, 1));
CHECK(!derContains(&one, 1, san, sanLen));
// Found at the very end of a buffer too -- an off-by-one in the loop bound shows up here
// and nowhere else, because the SAN is never at the end of a real certificate.
std::vector<uint8_t> tailCert(64, 0x5A);
tailCert.insert(tailCert.end(), san, san + sanLen);
CHECK(derContains(tailCert.data(), tailCert.size(), san, sanLen));
}
CASE("sanExtensionFullDer / derCarriesSan: the extension a client sees, or none");
{
uint8_t full[kSanExtensionFullDerMax];
prime(reinterpret_cast<char *>(full), sizeof(full));
const size_t n = sanExtensionFullDer(full, sizeof(full));
// SEQUENCE(41) { OID(3) 55 1D 11, OCTET STRING(34) <GeneralNames> }, non-critical so no
// BOOLEAN -- and these 43 bytes are lifted from a certificate OpenSSL issued for the
// same three names, not derived from this encoder's own output.
CHECK_EQ(n, 43);
CHECK_STR(hexOf(full, n).c_str(),
"30290603551d1104223020820e6b657972656c61792e6c6f63616c82086b657972"
"656c61798704c0a80401");
CHECK_EQ(sanExtensionFullDer(full, n), n); // exact fit
prime(reinterpret_cast<char *>(full), sizeof(full));
CHECK_EQ(sanExtensionFullDer(full, n - 1), 0); // one short writes nothing...
CHECK(untouched(reinterpret_cast<const char *>(full), n - 1));
CHECK_EQ(sanExtensionFullDer(nullptr, sizeof(full)), 0);
uint8_t san[kSanDerMax];
const size_t sanLen = sanExtensionDer(san, sizeof(san));
CHECK_EQ(sanLen, 34);
// A certificate that carries the extension this build writes: yes.
std::vector<uint8_t> cert(90, 0x3C);
sanExtensionFullDer(full, sizeof(full));
cert.insert(cert.end(), full, full + n);
cert.insert(cert.end(), {0x03, 0x47, 0x00});
CHECK(derCarriesSan(cert.data(), cert.size()));
CHECK(derCarriesSan(cert.data(), 90 + n)); // the extension ends exactly here
CHECK(!derCarriesSan(cert.data(), 90 + n - 1)); // one byte short of carrying it
// The certificate a board actually issued before this was fixed -- the right 34 value
// bytes, wrapped the right way, under the OID 1.10.46.53.46.50.57.46.49.55 (the ASCII
// of "2.5.29.17"). It has no subjectAlternativeName as far as any client is concerned,
// and this is where that becomes an observation instead of a surprise. Note what the
// weaker question answers here: the value really is in the certificate.
static const uint8_t kPreFix[] = {
0x30, 0x2F, 0x06, 0x09, '2', '.', '5', '.', '2', '9', '.', '1',
'7', 0x04, 0x22,
};
std::vector<uint8_t> legacy;
legacy.insert(legacy.end(), kPreFix, kPreFix + sizeof(kPreFix));
legacy.insert(legacy.end(), san, san + sanLen);
legacy.insert(legacy.end(), 40, 0x11);
CHECK(derContains(legacy.data(), legacy.size(), san, sanLen)); // the easy question: yes
CHECK(!derCarriesSan(legacy.data(), legacy.size())); // the real question: no
// One byte of the OID and it is a different extension: a name list under a label nobody
// asked for is the same failure in a different costume.
std::vector<uint8_t> shifted = cert;
const size_t oidAt = 90 + 4; // SEQUENCE(2) + tag + length of the OID
CHECK_EQ(shifted[oidAt], 0x55);
shifted[oidAt] = 0x54;
CHECK(!derCarriesSan(shifted.data(), shifted.size()));
// The value alone, or the OID alone, is not the extension.
std::vector<uint8_t> onlyValue(64, 0x07);
onlyValue.insert(onlyValue.end(), san, san + sanLen);
CHECK(!derCarriesSan(onlyValue.data(), onlyValue.size()));
std::vector<uint8_t> onlyOid(64, 0x07);
onlyOid.insert(onlyOid.end(), kSanOidTlv, kSanOidTlv + kSanOidTlvLen);
CHECK(!derCarriesSan(onlyOid.data(), onlyOid.size()));
// Nothing to search, and nothing to search with.
CHECK(!derCarriesSan(nullptr, 100));
CHECK(!derCarriesSan(cert.data(), 0));
}
CASE("dateTimeText: a validity bound as a person reads it");
{
char t[32];
prime(t, sizeof(t));
CHECK_EQ(dateTimeText(t, sizeof(t), 2026, 11, 8, 0, 0, 0), 23);
CHECK_STR(t, "2026-11-08 00:00:00 UTC");
CHECK_EQ(dateTimeText(t, sizeof(t), 2036, 11, 7, 23, 59, 59), 23);
CHECK_STR(t, "2036-11-07 23:59:59 UTC");
// Exactly enough room, and one byte short of it.
CHECK_EQ(dateTimeText(t, 24, 2026, 11, 8, 0, 0, 0), 23);
prime(t, sizeof(t));
CHECK_EQ(dateTimeText(t, 23, 2026, 11, 8, 0, 0, 0), 0);
CHECK(untouched(t, 23));
// The same component checks utcTime() applies before a certificate is written, including
// the February that does not exist -- a date this tree would never have encoded cannot be
// printed as if it had been.
CHECK_EQ(dateTimeText(t, sizeof(t), 2026, 2, 29, 0, 0, 0), 0);
CHECK_EQ(dateTimeText(t, sizeof(t), 2028, 2, 29, 0, 0, 0), 23);
CHECK_EQ(dateTimeText(t, sizeof(t), 2026, 13, 1, 0, 0, 0), 0);
CHECK_EQ(dateTimeText(t, sizeof(t), 2026, 0, 1, 0, 0, 0), 0);
CHECK_EQ(dateTimeText(t, sizeof(t), 2026, 11, 31, 0, 0, 0), 0);
CHECK_EQ(dateTimeText(t, sizeof(t), 2026, 11, 8, 24, 0, 0), 0);
CHECK_EQ(dateTimeText(t, sizeof(t), 2026, 11, 8, 0, 60, 0), 0);
// :60 is RFC 5280's leap-second slot, accepted here exactly as utcTime() accepts it.
CHECK_EQ(dateTimeText(t, sizeof(t), 2026, 11, 8, 23, 59, 60), 23);
// NOT utcTime's 1950..2049 window: reading a date is not the same permission as writing
// one, and a certificate outliving UTCTime is the writer's problem, not the reader's.
CHECK_EQ(dateTimeText(t, sizeof(t), 2050, 1, 1, 0, 0, 0), 23);
CHECK_EQ(dateTimeText(t, sizeof(t), 1949, 12, 31, 0, 0, 0), 23);
CHECK_EQ(dateTimeText(nullptr, sizeof(t), 2026, 11, 8, 0, 0, 0), 0);
}
return report("test_tls_pem");
}
+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");
}