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:
+8
-5
@@ -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"
|
||||
|
||||
@@ -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
@@ -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})`);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user