phase: 03_session_takeover

**Phase 03 `session_takeover` — final verification pass. All green.**

All four task files were already in `.agents/phases/complete/03_session_takeover/` with per-task reports, so this was the phase-level close, not new implementation.

**Completion criteria**
1. *2nd controller authenticates + takes the bridge; a 3rd is refused* — met host-side: `test_claim` 102 (whitelist matrix, `acceptsUpgrade` one-claim-at-a-time, both deadline clocks across `millis()` rollover, 64-byte cap through the real codec), `test_relayctl` 308 (`--take-over` = `T`,`X`,`H`; default refusal names the flag and sends no `X`), `test_page` 142 (claim→button→one click→`T` then `X` only). Refusal mechanism stated precisely: a second claim gets `503`/`E3`, a third *socket* gets the slot refusal (`slotrefused`).
2. *Handover releases then adopts, lands disarmed* — pinned: `closeLink("taken over")` before adoption (inverting it fails exactly 1 check, reverted); no `arm`/`setArmed`/`armed = true` in the `X` path (inserting one fails exactly 1 check, reverted); un-authenticated `X` admitted in `claim.h` fails `test_claim`+`test_relayctl` (reverted). Also verified by reading: `serveClaim` never writes `_lastInboundMs`/`_controllerSeen`, so a parked claim cannot hold the board armed past 8 s.
3. *Incumbent page says taken over, disarmed, no auto-reclaim* — `onE12()` + `test_page` (disarms, releases mouse, ends typing run, pill `taken over`, reconnect loop off).
4. *Three source pins fail on inversion* — re-demonstrated in this pass, all reverted (`src/claim.h`, `src/control_server.cpp` byte-identical to pre-inversion).
5. *Gate green + README/AGENTS say takeover cannot arm* — yes.

**Validation** — `bash .agents/validate.sh` → **exit 0**: 12 suites **5026 checks, 0 failures** (normal and `SANITIZE=1`), `bridge`/`console`/`rollback` all SUCCESS, page bytes 59 286, README/AGENTS claims OK, hygiene OK. Extra lint: `ruff --select F,E9` clean, `bash -n` clean, `py_compile` clean. Coverage on new/changed logic: `claim.h` 100 % (11 lines), `status.h` 100 % (29), `proto.h` 94.8 % (229), relayctl take-over lines 14/15 (the miss is a `def` continuation trace never records).

**Defects fixed (docs only — no code defect found)**: bug 6 claimed a 2nd `/ws` upgrade "is still refused"; Measured-behaviour samples lacked `taken=`/`busy`/`tkov`; footprint + TLS-cost rows quoted phase-02 numbers while claiming to be what the gate prints (now 99 744/1 257 933, 67 352/1 178 173, 93 568/1 157 609 → TLS = +6 176 B RAM/+100 324 B flash); page-delivery/handshake rows dated; `AGENTS.md` pure-module list was missing `claim.h`.

**Hardware**: no device run. The board still runs pre-phase-03 firmware, reflash/arming mutates a live device (the target is this machine), and both controllers need the operator's passphrase — guessing feeds the shared 5-fail lockout. The 3 blank rows (third-controller refusal, chord labels, nothing-outlives-the-link) are now recorded `skipped (no device run)` with the exact closing commands in `.agents/HW_VERIFICATION.md`.

**Tree left for the harness**: index untouched (0 staged), no history changes; `todo/03_session_takeover/00_phase.md` moved to `complete/` (matches the phase-02 rename pattern); untracked files the commit must include: `.agents/validate.sh`, `src/claim.h`, `tests/test_claim.cpp`, `.agents/phases/complete/03_session_takeover/`, `.agents/reports/03_session_takeover/`. Next phase: `todo/04_controller_page_responsive/`.
This commit is contained in:
2026-11-09 08:57:35 -05:00
parent e0780f58e0
commit d629a0ff51
45 changed files with 2884 additions and 161 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ images=(
)
suites=(test_keystate test_proto test_text test_mouse test_output test_ws_codec
test_tls_pem test_tls_session test_conn)
test_tls_pem test_tls_session test_conn test_claim)
build_and_run() {
local cxx="$1"
+186
View File
@@ -0,0 +1,186 @@
// tests for the pending takeover claim: tests/test_claim.cpp
//
// Phase 03 task 01 opens one door a crack: the spare socket may now complete the
// WebSocket handshake and authenticate as a *pending claim* instead of getting a 503.
// Nothing else is on the wire yet, so everything that decides what that socket is
// allowed to be -- which role, which commands, which clock -- lives in src/claim.h,
// and this suite walks it: the whitelist as a matrix (every Cmd, both auth states,
// both roles), the deadline arithmetic on both sides of the millis() rollover, and
// the claim's buffer against the widest frame it may receive -- refused, not
// buffered, measured through the real codec rather than asserted from the constant.
#include <string.h>
#include "claim.h"
#include "config.h"
#include "proto.h"
#include "test.h"
#include "ws_codec.h"
using kr::claim::acceptsUpgrade;
using kr::claim::expired;
using kr::claim::honours;
using kr::claim::ViewerRole;
using kr::proto::Cmd;
using kr::proto::Message;
using kr::proto::parse;
namespace {
// Every value proto::Cmd has. The matrix below walks it, so a new command added to
// the protocol is a new row that must be judged allowed-or-refused for a claim --
// the whitelist decides by name, and this list is what notices a name appears.
constexpr Cmd kAll[] = {Cmd::None, Cmd::Hello, Cmd::Auth,
Cmd::Arm, Cmd::Disarm, Cmd::State,
Cmd::SecureAttention, Cmd::Chord, Cmd::Text,
Cmd::Mouse, Cmd::ReleaseAll, Cmd::KeepAlive,
Cmd::Takeover, Cmd::Bad};
constexpr size_t kNumCmds = sizeof(kAll) / sizeof(kAll[0]);
// One masked client text frame, the only shape the codec accepts, sized for a
// payload below 126 bytes (the claim's whole budget is 64).
size_t maskedTextFrame(uint8_t *out, const uint8_t *text, size_t n) {
const uint8_t key[4] = {0x37, 0xfa, 0x11, 0xde};
size_t o = 0;
out[o++] = 0x81; // FIN + text
out[o++] = static_cast<uint8_t>(0x80 | n); // masked, 7-bit length
for (size_t i = 0; i < 4; i++) out[o++] = key[i];
for (size_t i = 0; i < n; i++) out[o++] = static_cast<uint8_t>(text[i] ^ key[i & 3]);
return o;
}
} // namespace
int main() {
CASE("the whitelist, unauthenticated: T is the only door");
for (size_t i = 0; i < kNumCmds; i++) {
CHECK_EQ(honours(ViewerRole::Claim, false, kAll[i]), kAll[i] == Cmd::Auth);
}
CASE("the whitelist, authenticated: T, K and the deliberate X -- nothing else");
for (size_t i = 0; i < kNumCmds; i++) {
const bool allowed = kAll[i] == Cmd::Auth || kAll[i] == Cmd::KeepAlive ||
kAll[i] == Cmd::Takeover;
CHECK_EQ(honours(ViewerRole::Claim, true, kAll[i]), allowed);
}
// X is allowed ONLY here, in the authenticated branch: it is the one deliberate act
// the door exists for, and control_server.cpp::handOverClaim -- the only path it
// triggers -- runs the release funnel and lands the board DISARMED. The claim socket
// that reaches it has already proven the passphrase.
CHECK(honours(ViewerRole::Claim, true, Cmd::Takeover));
// Refused by name, because this list is the safety argument: reaching the target,
// disturbing the incumbent, or moving the arming line are all refused here.
CHECK(!honours(ViewerRole::Claim, true, Cmd::Arm)); // no arm
CHECK(!honours(ViewerRole::Claim, true, Cmd::Disarm));
CHECK(!honours(ViewerRole::Claim, true, Cmd::State)); // no keystrokes
CHECK(!honours(ViewerRole::Claim, true, Cmd::SecureAttention)); // no Ctrl+Alt+Del
CHECK(!honours(ViewerRole::Claim, true, Cmd::Chord));
CHECK(!honours(ViewerRole::Claim, true, Cmd::Text)); // no paste
CHECK(!honours(ViewerRole::Claim, true, Cmd::Mouse)); // no pointer
CHECK(!honours(ViewerRole::Claim, true, Cmd::ReleaseAll)); // no R either
CHECK(!honours(ViewerRole::Claim, true, Cmd::Hello)); // no status
CHECK(!honours(ViewerRole::Claim, true, Cmd::Bad)); // not a conversation
CASE("a Readonly slot honours no frame at all, authenticated or not");
for (size_t i = 0; i < kNumCmds; i++) {
CHECK(!honours(ViewerRole::Readonly, false, kAll[i]));
CHECK(!honours(ViewerRole::Readonly, true, kAll[i]));
}
// Including the takeover ask: a slot that never did the upgrade cannot claim a bridge.
CHECK(!honours(ViewerRole::Readonly, true, Cmd::Takeover));
CASE("one claim at a time; a read-only slot may be turned into one");
CHECK(acceptsUpgrade(ViewerRole::Readonly));
CHECK(!acceptsUpgrade(ViewerRole::Claim));
CASE("deadlines: authenticate on the controller's clock, then wait our own");
{
const uint32_t anchor = 1234567;
// Unauthenticated: exactly the controller's kAuthTimeoutMs, strictly greater.
CHECK(!expired(false, anchor, anchor));
CHECK(!expired(false, anchor + kr::kAuthTimeoutMs, anchor));
CHECK(expired(false, anchor + kr::kAuthTimeoutMs + 1, anchor));
// Authenticated: the auth clock no longer applies -- a claim that got its `OK`
// at the worst legal moment is still a claim -- and kClaimTimeoutMs governs.
CHECK(!expired(true, anchor + kr::kAuthTimeoutMs + 1, anchor));
CHECK(!expired(true, anchor + kr::kClaimTimeoutMs, anchor));
CHECK(expired(true, anchor + kr::kClaimTimeoutMs + 1, anchor));
CHECK(kr::kClaimTimeoutMs > kr::kAuthTimeoutMs); // 15 s for a click, 5 s for T
// Across the 49.7-day millis() rollover the arithmetic is elapsed time, not
// magnitude: a live claim is not evicted and a dead one is not immortal.
const uint32_t nearWrap = 0xFFFFFF00u;
CHECK(!expired(false, nearWrap + 4000, nearWrap));
CHECK(expired(false, nearWrap + kr::kAuthTimeoutMs + 1, nearWrap)); // wraps
CHECK(!expired(true, nearWrap + 14000, nearWrap));
CHECK(expired(true, nearWrap + kr::kClaimTimeoutMs + 1, nearWrap)); // wraps
}
CASE("the claim's budget: every legal frame fits, anything larger is refused");
{
// The sizing claim, pinned as numbers: the widest frame a claim may receive is
// `T<token>` at kTokenMax + 1, the cap is that with headroom, and it is exactly
// one quarter of the controller's kWsMessageCap (config.h carries the reasoning;
// a draft said sixteenth, the arithmetic says four, and the arithmetic wins).
CHECK(kr::kTokenMax + 1 <= kr::kClaimMsgCap);
CHECK_EQ(kr::kWsMessageCap / kr::kClaimMsgCap, 4u);
CHECK(kr::kClaimMsgCap < kr::kWsMessageCap);
// The widest legal T frame parses, and fits the buffer with room to spare.
char widest[ kr::kTokenMax + 2];
widest[0] = 'T';
memset(widest + 1, 'a', kr::kTokenMax);
widest[kr::kTokenMax + 1] = '\0';
Message m;
CHECK(parse(widest, kr::kTokenMax + 1, m) == Cmd::Auth);
CHECK(strlen(widest) < kr::kClaimMsgCap);
}
CASE("an oversized claim frame is refused by the codec, not buffered");
{
uint8_t msg[kr::kClaimMsgCap + 16];
memset(msg, 'z', sizeof(msg));
// Exactly at the cap: the reader completes (the frame is refused or accepted by
// the whitelist later -- the codec's answer is "it fits").
uint8_t buf[kr::kClaimMsgCap];
kr::ws::Reader r{buf, sizeof(buf)};
uint8_t frame[256];
size_t n = maskedTextFrame(frame, msg, kr::kClaimMsgCap);
kr::ws::In in = kr::ws::In::NeedMore;
for (size_t i = 0; i < n && in == kr::ws::In::NeedMore; i++) in = r.feed(frame[i]);
CHECK(in == kr::ws::In::Text);
// One byte over: an error, not a truncation, not a growing buffer. The board's
// RAM is not somebody else's allocation request.
kr::ws::Reader r2{buf, sizeof(buf)};
n = maskedTextFrame(frame, msg, kr::kClaimMsgCap + 1);
in = kr::ws::In::NeedMore;
for (size_t i = 0; i < n && in == kr::ws::In::NeedMore; i++) in = r2.feed(frame[i]);
CHECK(in == kr::ws::In::Error);
CHECK(r2.error() == kr::ws::Err::TooLong);
}
CASE("X is the takeover command, and the frames the whitelist names");
{
// Task 02 put `X` on the wire: it parses to Takeover (trailing bytes are Bad --
// test_proto.cpp walks that in full), and it is the only frame that changed side
// of the whitelist since task 01. The rest still parse as the whitelist comment
// says they do, because that comment and this list are the same argument.
Message m;
CHECK(parse("X", 1, m) == Cmd::Takeover);
CHECK(parse("XX", 2, m) == Cmd::Bad);
// And the frames named by the whitelist comment really are the commands named:
CHECK(parse("K", 1, m) == Cmd::KeepAlive);
CHECK(parse("Tchangeme", 9, m) == Cmd::Auth);
CHECK(parse("A1", 2, m) == Cmd::Arm);
CHECK(parse("A0", 2, m) == Cmd::Disarm);
CHECK(parse("R", 1, m) == Cmd::ReleaseAll);
CHECK(parse("C", 1, m) == Cmd::SecureAttention);
CHECK(parse("H", 1, m) == Cmd::Hello);
CHECK(parse("S=00:", 5, m) == Cmd::State);
CHECK(parse("Y=02:04", 7, m) == Cmd::Chord);
CHECK(parse("P=0:6162", 8, m) == Cmd::Text);
CHECK(parse("M=00:0:0:0:0", 12, m) == Cmd::Mouse);
}
return report("test_claim");
}
+6 -6
View File
@@ -67,7 +67,7 @@ namespace {
// 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;
constexpr size_t kServedPageBytes = 59286;
/** The pump's shape, without a socket: hand out chunks, accept what a peer takes. */
struct DrainLog {
@@ -311,10 +311,10 @@ int main() {
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
// 59 286 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
// A stand-in of the served page's size (59 286 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.
@@ -342,10 +342,10 @@ int main() {
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
// 59 286 / 1460 = 41 chunks; / 5840 per tick = 11 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);
CHECK_EQ((sizeof(big) + kPageBytesPerTick - 1) / kPageBytesPerTick, 11);
CHECK_EQ((sizeof(big) + kPageChunkBytes - 1) / kPageChunkBytes, 41);
}
CASE("config: the sizing this layer promises");
+113
View File
@@ -141,6 +141,9 @@ const expose = `
get armed() { return armed; }, set armed(v) { armed = v; },
get authed() { return authed; }, set authed(v) { authed = v; },
get typing() { return typing; },
get autoReconnect() { return autoReconnect; },
get busyKnown() { return busyKnown; },
get takenOver() { return takenOver; },
};`;
const headerPath = path.join(__dirname, "..", "src", "web_page.h");
@@ -626,6 +629,116 @@ checkEq(!!x.inPageField({ target: null }), false, "a missing target does not thr
checkEq(byId("certFp").textContent.includes("00aa11bb22cc33dd"), true,
"a new status re-renders the new fingerprint");
console.log("the busy board and the take-over");
// Phase 03, page side. The board reports "held" to a socket it authenticated but
// did not adopt (the status frame belongs to the controller alone), and to a
// session it displaces (E12). The page's whole job is to make that visible, offer
// one deliberate click, and NOT fight: no retry loop, no auto-reclaim, and --
// until the click -- not one frame about the bridge.
// `busy` in a status frame, seen by a page that is not the controller, is the
// link pill's third honest state. A controller reading busy:1 is reading itself,
// and keeps "linked" -- the OK handler says so; applyStatus must not contradict it.
byId("takeover").hidden = true;
x.authed = false; x.armed = false;
x.onFrame('S{"armed":0,"usb":1,"typing":0,"busy":1}');
checkEq(byId("pLink").textContent, "held by another controller",
"a busy status frame drives the link pill's third state");
check(byId("pLink").className.includes("bad"),
"and it is not the calm 'linked' state");
checkEq(x.busyKnown, true, "and the page now knows the board is held");
x.onFrame("OK"); // adopted: the ordinary path
checkEq(byId("pLink").textContent, "linked",
"the adopted controller reads its own busy as linked, not as a rival");
// A refused upgrade: the board parks this socket as a pending claim -- `OK`, then
// the E6-and-drop that answers the ordinary `H` on a claim slot -- and the page
// must say "held", offer the button, and stop, instead of retrying every 2 s.
sandbox.location.protocol = "https:";
byId("token").value = "hunter2";
FakeWebSocket.instances.length = 0;
sentFrames.length = 0;
x.authed = false; x.armed = false;
x.connect();
await wait(5);
inst = FakeWebSocket.instances[FakeWebSocket.instances.length - 1];
inst.onmessage({ data: "OK" }); // authenticated -- as a claim
const afterConnect = sentFrames.length; // T + H from the ordinary flow
inst.onmessage({ data: "E6:a pending claim may not send that" });
inst.onclose(); // and the claim is dropped
checkEq(byId("pLink").textContent, "held by another controller",
"the un-adopted socket reads the board's silence as 'held'");
checkEq(byId("takeover").hidden, false,
"and the take-over button appears -- only now that the board said held");
checkEq(sentFrames.length, afterConnect,
"after the refusal the page sends nothing on its own -- no retry, no X");
checkEq(x.autoReconnect, false,
"the reconnect loop is off: re-claiming automatically is a fight");
checkEq(FakeWebSocket.instances.length, 1, "and no second socket was opened");
// The one deliberate click: open, authenticate, ask for the bridge. T then X,
// in that order, and nothing else -- no arm, no state, not even an H.
byId("takeover").fire("click");
await wait(5);
checkEq(FakeWebSocket.instances.length, 2, "one click opened one takeover socket");
const t = FakeWebSocket.instances[1];
checkEq(sentFrames.slice(afterConnect).join(","), "Thunter2,X",
"the click sends T then X, in that order, and nothing else");
checkEq(byId("takeover").hidden, true, "and the button retires once the act is sent");
t.onmessage({ data: "OK" }); // adopted: OK, then the status
t.onmessage({ data: 'S{"armed":0,"usb":1,"typing":0,"busy":1}' });
checkEq(x.armed, false,
"the newcomer behaves like any controller, starting DISARMED");
checkEq(byId("pLink").textContent, "linked", "and it holds the bridge now");
const tlog = logEl.children.map(c => String(c.textContent)).join("\n");
check(tlog.includes("bridge taken over") && tlog.includes("DISARMED"),
"the log names what happened, including the state it landed in");
// E3 keeps its meaning next to E12's: "you were refused -- no room for a claim",
// not "you were dropped". It still disarms a page that asks; it does not claim
// this session was displaced. (Checked BEFORE the E12 walk, so "not a takeover"
// means E3 did not set it, not that an earlier takeover was already on record.)
x.setArmed(true, "harness: armed");
x.onFrame("E3:no room");
checkEq(x.armed, false, "E3 still refuses the ask and disarms");
checkEq(x.takenOver, false, "but E3 is not a takeover -- nothing displaced anyone");
const e3log = logEl.children.map(c => String(c.textContent)).join("\n");
check(e3log.includes("no room for a claim"),
"and its log line says 'no room', the sentence that distinguishes it from E12");
// The incumbent's side, which this page now is: E12 disarms, releases the mouse,
// ends a typing run mid-flight, marks the pill, and keeps the page down -- the
// same gesture the board itself ran through the funnel.
x.setArmed(true, "harness: armed");
x.takeMouse();
await wait(2);
checkEq(x.mCaptured, true, "set-up: this session holds the mouse");
byId("paste").value = "run".repeat(100); // three frames' worth
sentFrames.length = 0;
p = x.sendPaste();
await wait(5);
x.onFrame("E12:session taken over");
checkEq(x.armed, false, "E12 disarms the incumbent immediately");
checkEq(x.mCaptured, false, "and drops the mouse");
x.onFrame('S{"armed":0,"usb":1,"typing":0}'); // release the run's waiter
await p;
checkEq(x.typing, false, "and ends the typing run");
checkEq(frames().length, 1, "the run stops -- no further P= frames after a takeover");
checkEq(byId("pLink").textContent, "taken over", "and the pill says what happened");
const ilog = logEl.children.map(c => String(c.textContent)).join("\n");
check(ilog.includes("another controller took over"),
"and the log carries the reason, in the incumbent's own words");
checkEq(x.autoReconnect, false, "and this page stays down until someone clicks");
inst = FakeWebSocket.instances[FakeWebSocket.instances.length - 1];
const nSockets = FakeWebSocket.instances.length;
inst.onclose();
checkEq(FakeWebSocket.instances.length, nSockets,
"the close after E12 opens nothing -- no auto-reclaim");
checkEq(byId("pLink").textContent, "taken over", "the pill keeps the fact");
checkEq(byId("takeover").hidden, false,
"coming back is one deliberate click away, never zero");
x.setArmed(false, "harness: done");
console.log("element ids");
const markup = served;
const present = new Set();
+53 -5
View File
@@ -45,9 +45,24 @@ int main() {
CHECK(parse("C") == Cmd::SecureAttention);
CHECK(parse("A1") == Cmd::Arm);
CHECK(parse("A0") == Cmd::Disarm);
CHECK(parse("X") == Cmd::Takeover); // the pending claim's one deliberate act
// hand-typed clients end lines with \n: tolerated
CHECK(parse("A1\n") == Cmd::Arm);
CHECK(parse("R\r\n") == Cmd::ReleaseAll);
CHECK(parse("X\r\n") == Cmd::Takeover); // tolerated here exactly like the others
}
CASE("the takeover frame: what a claimant puts on the wire is what the board parses");
{
// There is no formatX(): the client side of `X` is the single byte, and this is
// the round trip -- the exact bytes the page or relayctl will send, run through
// the firmware's own parser, plus the name the diagnostics print.
const char frame[] = "X";
Message m;
CHECK(kr::proto::parse(frame, sizeof(frame) - 1, m) == Cmd::Takeover);
CHECK(std::string(kr::proto::name(Cmd::Takeover)) == "takeover");
// Anything else starting with X is not a takeover and not a conversation.
CHECK(kr::proto::parse("XK", 2, m) == Cmd::Bad);
}
CASE("garbage is rejected, never half-accepted");
@@ -56,6 +71,9 @@ int main() {
CHECK(parse("A") == Cmd::Bad); // arm/disarm needs its digit
CHECK(parse("A2") == Cmd::Bad);
CHECK(parse("A11") == Cmd::Bad);
CHECK(parse("XX") == Cmd::Bad); // `X` is one command; trailing bytes are Bad
CHECK(parse("X1") == Cmd::Bad); // ... even bytes that look meaningful
CHECK(parse("X ") == Cmd::Bad); // ... or not: one frame, one command
CHECK(parse("HH") == Cmd::Bad);
CHECK(parse("Z") == Cmd::Bad);
CHECK(parse("hello") == Cmd::Bad);
@@ -464,6 +482,11 @@ int main() {
CHECK_STR(buf, "E10:off");
CHECK_EQ(kr::proto::formatError(buf, sizeof(buf), 11, "boot"), 8u);
CHECK_STR(buf, "E11:boot");
// E12 is the incumbent's sentence ("you were dropped"), and it renders at full
// width like 10/11 -- a client that was told "E1" by truncation would tell its
// operator the passphrase failed instead of the bridge was taken.
CHECK_EQ(kr::proto::formatError(buf, sizeof(buf), 12, "session taken over"), 22u);
CHECK_STR(buf, "E12:session taken over");
CHECK_EQ(kr::proto::formatError(buf, sizeof(buf), 0, "z"), 4u);
CHECK_STR(buf, "E0:z");
@@ -549,15 +572,35 @@ int main() {
char json[kr::kStatusJsonCap];
const char *fp = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
// A default snapshot: no TLS, and no label for an identity that is not there.
// A default snapshot: no TLS, no label for an identity that is not there, and a
// free bridge with no handover behind it.
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(strstr(json, "\"busy\":0") != nullptr);
CHECK(strstr(json, "\"tkov\":0") != nullptr);
CHECK_EQ(kr::kStatusFingerprintBytes, 8);
CHECK_EQ(kr::kStatusFingerprintHex, 16);
// The bridge, in the only terms the board may know: a link boolean and a
// counter. busy:1 says "a controller holds the keyboard" -- to a /status poller,
// someone else; to the adopted controller, itself -- and tkov counts completed
// handovers. There is no field that could say WHO, and walking these two is what
// keeps the next "just add the client IP" from passing review unnoticed: the
// README's never-stored list is about exactly this temptation.
StatusSnapshot held;
held.busy = true;
held.takeovers = 3;
n = statusJson(held, json, sizeof(json));
CHECK(n > 0);
CHECK(strstr(json, "\"busy\":1") != nullptr);
CHECK(strstr(json, "\"tkov\":3") != nullptr);
CHECK(strstr(json, "addr") == nullptr); // the only ip here is the board's own
CHECK(strstr(json, "agent") == nullptr);
CHECK(strstr(json, "peer") == nullptr);
// 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.
@@ -612,10 +655,11 @@ int main() {
// 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.
// of the five counters, UINT16_MAX in `typing`, every LED bit and every mouse button,
// `busy` set, `tkov` at UINT32_MAX, 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;
@@ -637,6 +681,8 @@ int main() {
worst.ledState = 7; // caps/num/scroll: the three bits a report carries
worst.defaultToken = true;
worst.apForced = true;
worst.busy = true;
worst.takeovers = 0xFFFFFFFFu;
worst.tls = true;
worst.fp = fp;
char big[kr::kStatusJsonCap + 16];
@@ -650,6 +696,8 @@ int main() {
CHECK(strchr(big, '\n') == nullptr); // one frame, one line
CHECK(strstr(big, "\"tls\":1") != nullptr);
CHECK(strstr(big, "\"fp\":\"ba7816bf8f01cfea\"") != nullptr);
CHECK(strstr(big, "\"busy\":1") != nullptr);
CHECK(strstr(big, "\"tkov\":4294967295") != 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.
+199 -10
View File
@@ -382,6 +382,84 @@ 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("the bridge handover (phase 03)")
# `X` moves the keyboard from one socket to the other, and the safety sentences about it
# are ordering facts in control_server.cpp that no host-runnable test can observe -- they
# need two real sockets and a board. Same trade as bug 10's pins above: these are source
# pins, deliberately, for the three sentences a later edit can undo silently.
server_hdr = (root / "src" / "control_server.h").read_text()
claim_src = (root / "src" / "claim.h").read_text()
hand_at = server_src.index("void ControlServer::handOverClaim")
hand = server_src[hand_at:server_src.index("\n}", hand_at)]
# Code lines only -- the function's comment is full of the words it forbids itself, and
# a check that flagged the comment would be worse than no check at all.
hand_code = "\n".join(l for l in hand.splitlines()
if not l.strip().startswith(("//", "*", "/*")))
# (a) The funnel runs BEFORE the newcomer is adopted. closeLink() is the release funnel
# (it disarms and releases for anything that reached the WebSocket phase -- bug 10's
# fix -- and runs _onLinkChange(false), which in main.cpp is disarm("controller
# disconnected") -> releaseEverything()); adopting the claimant is `_authed = true`.
# If those two ever swap order, the newcomer inherits a held key and an armed
# bridge, and nothing on a host would ever notice.
check("closeLink(\"taken over\")" in hand_code,
"the handover runs the existing release funnel, not a copy of it")
check("_authed = true" in hand_code,
"and it is the code that adopts the claimant as the controller")
check(hand_code.index("closeLink(\"taken over\")") < hand_code.index("_authed = true"),
"the funnel runs BEFORE the claimant is adopted -- a takeover can never inherit "
"a held key or an armed bridge")
check("if (!linked) disarm(\"controller disconnected\");" in main_src,
"and the funnel's link change is the one main.cpp disarms (releases every key "
"and every mouse button) on")
# (b) No arm is reachable from the `X` handler. The board lands DISARMED by design; the
# newcomer arms with an ordinary authenticated A1 or never. `arm(`/`setArmed`/"armed =
# true" must not appear in the handover's code lines, nor in the controller-side `X`
# case in main.cpp (which is a no-op answered OK).
for tok in ("setArmed", "armed = true", "arm("):
check(tok not in hand_code,
f"the handover path contains no {tok!r} -- a takeover cannot produce an "
f"armed board")
mt_at = main_src.index("case proto::Cmd::Takeover:")
mt_case_raw = main_src[mt_at:main_src.index("return;", mt_at)]
# Comment lines stripped for the same reason as hand_code: the case's own comment
# explains what it must not do, in words that contain the words being searched for.
mt_case = "\n".join(l for l in mt_case_raw.splitlines()
if not l.strip().startswith(("//", "*", "/*")))
check("control.sendOk();" in mt_case,
"`X` from the incumbent controller is a no-op answered OK, not E6")
check("setArmed" not in mt_case and "arm(" not in mt_case,
"and it cannot arm either -- you already hold the bridge, that is all this says")
# (c) A claim cannot be promoted without `_claimAuthed`. Two halves: the whitelist is
# consulted before the Takeover branch in handleClaimFrame, and claim.h admits `X`
# only in the authenticated branch, so the ordering has something correct to check.
hcf_at = server_src.index("bool ControlServer::handleClaimFrame")
hcf = server_src[hcf_at:server_src.index("\n}", hcf_at)]
check("claim::honours(_viewerRole, _claimAuthed, cmd)" in hcf,
"handleClaimFrame asks the one whitelist, with _claimAuthed in the question")
check(hcf.index("claim::honours(") < hcf.index("proto::Cmd::Takeover"),
"and it asks BEFORE the Takeover branch -- `X` cannot reach the promotion "
"unauthenticated")
check_eq(server_src.count("handOverClaim(nowMs);"), 1,
"handOverClaim is called from exactly that one place, behind the whitelist")
hon_at = claim_src.index("inline bool honours(")
hon = claim_src[hon_at:claim_src.index("\n}", hon_at)]
check("if (!authed) return cmd == proto::Cmd::Auth;" in hon,
"claim.h admits only T before authentication -- an unauthenticated `X` is refused")
check(hon.index("if (!authed)") < hon.index("case proto::Cmd::Takeover"),
"and `X` lives in the authenticated branch only, after that refusal")
# E12 is its own sentence: "you were dropped", not E3's "you were refused". The named
# code and the incumbent-side send are both pinned so the two can never merge back.
check("kErrTakenOver = 12" in server_hdr,
"the error code is named at 12, the number the incumbent's page will read")
check('sendError(kErrTakenOver, "session taken over")' in hand_code,
"and it is that named code the incumbent is told with, before the funnel closes "
"its link")
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
@@ -401,11 +479,16 @@ check("if (_phase == Phase::WebSocket && _authed) _lastInboundMs = millis();" in
"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.
# ... and it is the ONLY inbound path that may. Every writer of that clock is a route
# that can keep the board armed, so the count is pinned: readSome, the authentication,
# closeLink stamping on the way out, and the bridge handover ADOPTING an already-
# authenticated claim (phase 03 -- the claim was checked against the same passphrase,
# so from that instant a controller exists and is speaking; the adoption stamps exactly
# the stamp an auth would). A fifth writer needs its sentence written here first.
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_eq(len(stamps), 4,
"exactly four writers of the controller-silence clock: auth, readSome, "
"closeLink, handover adoption")
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
@@ -590,17 +673,33 @@ for bad in ("", "deadbeef", "zz" * 32, "ab" * 31, "ab" * 32 + "0"):
FAKE_DER = b"-----BEGIN FAKE CERTIFICATE----- der-bytes pinning never sees"
# The status an adopted controller gets back to back with its `OK` -- the frame
# whose ABSENCE after `OK` is how a pending claim learns the board is busy. The
# values are the honest post-handover ones: disarmed, busy (with itself), one
# completed takeover.
FAKE_STATUS = b'S{"v":"1.0.0","armed":0,"usb":1,"typing":0,"busy":1,"tkov":1}'
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.
`claim_slot=True` makes the board a BUSY one: `T` authenticates into the spare
slot as a pending claim (`OK`, and deliberately no adoption status -- the status
frame belongs to the controller alone), and the board adopts the client only
when it sends the one deliberate `X`. That asymmetry is the whole of phase 03
from this side of the socket, so the fake spells it out instead of collapsing
both answers into "OK".
"""
def __init__(self, host, port, close_early=False, bad_accept=False, flood=False):
def __init__(self, host, port, close_early=False, bad_accept=False, flood=False,
claim_slot=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.claim_slot = claim_slot # busy board: park the newcomer as a claim
self.sent = [] # every sendall payload, in order
self.inbox = bytearray()
self.upgraded = False
@@ -639,6 +738,20 @@ class FakeBoardSock:
del self.post[:6 + length]
if payload.startswith(b"T"):
self.inbox += relayctl_server_frame(relayctl.OP_TEXT, b"OK")
if not self.claim_slot:
# The adoption status a real controller gets back to back with
# the OK -- the auth path sends both on one write.
self.inbox += relayctl_server_frame(relayctl.OP_TEXT, FAKE_STATUS)
elif payload == b"X":
# Only a claim has anything to take; this fake only ever sees X
# from one. The handover lands disarmed, and the adopted session
# gets exactly the greeting a fresh authentication gets.
if self.claim_slot:
self.claim_slot = False
self.inbox += relayctl_server_frame(relayctl.OP_TEXT, b"OK")
self.inbox += relayctl_server_frame(relayctl.OP_TEXT, FAKE_STATUS)
elif payload == b"H":
self.inbox += relayctl_server_frame(relayctl.OP_TEXT, FAKE_STATUS)
def recv(self, n):
if self.inbox:
@@ -706,14 +819,15 @@ class FakeSsl:
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,
empty_der=False, bad_accept=False, flood=False, claim_slot=False,
take_over=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)
bad_accept=bad_accept, flood=flood, claim_slot=claim_slot)
socks.append(s)
return s
@@ -725,7 +839,8 @@ def connect_with(fingerprint="", ca="", insecure=False, plain=False, port=None,
with contextlib.redirect_stderr(stderr):
relay = relayctl.Relay("keyrelay.local", port, token, 1.0,
fingerprint=fingerprint, ca=ca,
insecure=insecure, plain=plain)
insecure=insecure, plain=plain,
take_over=take_over)
except relayctl.RelayError as exc:
err = exc
finally:
@@ -835,10 +950,47 @@ frames = client_frame_payloads(socks[0])
check(frames and frames[0] == b"Tsekrit",
"the rollback session authenticates the same way")
print("the busy board and --take-over")
# Phase 03, from the terminal side. A busy board authenticates this tool into its
# one spare slot as a pending claim -- `OK` and then the silence that means `busy`,
# because the status frame belongs to the controller alone. The default is a
# refusal that names the flag; the flag is one deliberate `X`; a free board is a
# no-op. The frames that DO go out are asserted, in order, because "takeover can
# never arm" is a sentence about what a session looks like at the moment it takes
# over: T, X, and nothing that types.
# Default against a busy board: refuse, name --take-over, send no X, free the slot.
relay, err, socks, fake, warns = connect_with(plain=True, claim_slot=True)
check(isinstance(err, relayctl.RelayError), "a busy board refuses by default")
check(err is not None and "--take-over" in str(err), "and the refusal names the flag")
check(err is not None and "DISARMED" in str(err),
"and says what the handover lands on, before anyone takes it")
check_eq(client_frame_payloads(socks[0]), [b"Tsekrit"],
"the refusal sends no X -- the act needs the flag, not just the passphrase")
check(socks and socks[0].closed, "and the claim slot is freed rather than parked on")
# With the flag: the claim's one deliberate frame, in the right order, alone.
relay, err, socks, fake, warns = connect_with(plain=True, claim_slot=True,
take_over=True)
check(err is None and relay is not None, f"--take-over takes a busy board ({err})")
check(relay is not None and relay.took_over, "and the session knows it took the bridge")
check_eq(client_frame_payloads(socks[0]), [b"Tsekrit", b"X"],
"T then X, in that order, and nothing else -- one intent, no arm, no state")
# --take-over against a free board is a no-op, not an error, and never wastes an X
# on a board that has nothing to take.
relay, err, socks, fake, warns = connect_with(plain=True, take_over=True)
check(err is None and relay is not None, f"--take-over on a free board succeeds ({err})")
check(relay is not None and relay.busy is False and not relay.took_over,
"and it reports the board was free")
check_eq(client_frame_payloads(socks[0]), [b"Tsekrit"],
"a free board is never sent an X it did not need")
# --- main(): the refusals that need no board -----------------------------------
def run_main(argv, fp_env=None, token_env="hunter2secret"):
def run_main(argv, fp_env=None, token_env="hunter2secret", claim_slot=False,
socks_out=None):
"""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
@@ -847,7 +999,10 @@ def run_main(argv, fp_env=None, token_env="hunter2secret"):
def record_create(addr, timeout):
calls.append(addr)
return FakeBoardSock(addr[0], addr[1])
s = FakeBoardSock(addr[0], addr[1], claim_slot=claim_slot)
if socks_out is not None:
socks_out.append(s)
return s
old_argv = sys.argv
old_env = os.environ.get("KEYRELAY_TLS_FP"), os.environ.get("KEYRELAY_TOKEN")
@@ -919,6 +1074,40 @@ 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")
# The whole takeover gesture through main(): take the held bridge, then read the
# board. T, X, H -- claim, handover, status -- with the terminal told which path
# ran, and no credential in anything it prints.
board = []
code, out, err_text, calls = run_main(["--plain", "--take-over", "status"],
token_env="sekrit", claim_slot=True,
socks_out=board)
check_eq(code, 0, f"--take-over status against a busy board runs ({err_text})")
session = client_frame_payloads(board[0]) if board else []
check_eq([f for f in session if f in (b"Tsekrit", b"X", b"H")],
[b"Tsekrit", b"X", b"H"],
"the busy session's frames are T, then X, then H -- claim, handover, status")
check("took the bridge" in out and "DISARMED" in out,
"and the terminal is told what the handover did before anything else runs")
check("sekrit" not in out and "sekrit" not in err_text,
"and the passphrase stays out of the terminal")
board = []
code, out, err_text, calls = run_main(["--plain", "--take-over", "status"],
token_env="sekrit", socks_out=board)
check_eq(code, 0, f"--take-over against a free board still runs ({err_text})")
check("nothing to take" in out, "and says it was a no-op, in those words")
check_eq([f for f in (client_frame_payloads(board[0]) if board else []) if f == b"X"],
[], "with no X on the wire at all")
# The default against a busy board, through main(): exit 1, the refusal names
# --take-over, and the wire never carried an X.
code, out, err_text, calls = run_main(["--plain", "status"], token_env="sekrit",
claim_slot=True, socks_out=board)
check_eq(code, 1, "a busy board is a refusal through main() too")
check("--take-over" in err_text, "and the refusal names the flag in the terminal")
check_eq([f for f in (client_frame_payloads(board[-1]) if board else [])
if f == b"X"], [], "and refused means refused: no X went out anyway")
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