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