TLS Alone Doesn't Know Who's Calling — Lynsi's Handshake Gate, and the Test Script That Lied to Me Twice
TLS gives you a private channel; it doesn't tell you who is on the other end of it. Lynsi's Mac receiver used to process every frame the moment TCP came up — an unpaired peer could write its clipboard. This is the design of the gate that fixed it: unauthenticated connections may send exactly one kind of frame, get five seconds, and receive nothing back — plus the two bugs in my own verification script, each of which made the test results the exact opposite of the truth.
Lynsi’s phone and Mac speak a small framed protocol over TLS 1.3, with the Mac’s certificate pinned by the phone at pairing time (the previous post covers where that certificate comes from). This post is about the gate that sits above TLS — why it has to exist, how it’s shaped, and how I verify it actually holds.
It started as a real hole
An early version of the Mac receiver processed every frame the moment TCP was established. Meaning: a peer that had never paired could skip enrolment entirely and write to the Mac’s clipboard, or push files into its Downloads folder.
The cause is a classic: during development both ends are your own devices, “connected” and “paired” feel like one thing, and the code faithfully implements them as one thing. They must be two. TLS establishes a private channel — it guarantees no third party is listening — but it says nothing about who is sitting at the other end of that channel. Certificate pinning is the phone verifying the Mac’s identity; in the other direction, the Mac knows nothing about whoever just connected.
Three rules at the gate
So a handshake gate sits above TLS. A freshly accepted connection is unauthenticated, and while it is:
- The only frame the Mac will read is
hello. Any other kind arriving drops the connection outright — not the frame, the connection. A peer capable of sending anything else is a peer that skipped pairing; there is nothing further to discuss. - Nothing is written back. Not one byte. No
hello.ack, no error, no explanation. Any response is free information for a scanner (“something lives on this port, and it dislikes my frames”). A zero-byte output surface leaks zero bits. - Five seconds to send the
hello, or the connection is dropped. Generous for software that knows what it’s supposed to send; a hard ceiling for a stranger sitting on the socket saying nothing.
Two implementation details are worth recording:
- The timer is armed at accept, not at ready. A connection that stalls inside the TLS handshake never reaches ready; arm the timer on ready and that half-open connection holds its slot forever.
- Held connections are capped, and every one of them is behind the gate. The cap bounds what an unauthenticated stranger can occupy — a decoder and a socket, reclaimed within five seconds. The design only ever uses two connections (the session plus one share-sheet lane), so hitting the cap is itself a signal: half-open stragglers, already being reaped by their own handshake timers.
The hello carries a pairToken, checked against three possibilities in decreasing order of what they prove: it matches a per-device credential this Mac previously minted (the steady-state path, unaffected by QR rotation); it equals the token currently in the QR code (first-time enrolment, after which a credential is minted on the spot); or the peer arrived on loopback and presented the literal usb-link — the USB cable path, and even then it isn’t admitted automatically: the Mac shows an approval prompt for a human to accept.
The cable path used to be much wider: “peer address is loopback” alone was enough. That exceeded the intent in two directions at once — every process on the Mac itself satisfies that condition, and a phone whose credential had since been revoked could plug in a cable and walk back in. So the cable has to be asked for by name (the sentinel), plus a human approval; a phone that has actually paired presents its real credential whichever way it connects, cable included.
The blast radius of a rejection is one
A rejected hello — wrong token, cable approval denied — drops only the connection that sent it. The rule sounds bland; what it protects is not. A stranger’s garbage hello must not be able to take down the phone’s live session — otherwise “throw bad hellos at that Mac” becomes a free denial-of-service button.
The same isolation shapes the share lane. HarmonyOS share extensions run in a separate process that cannot reach the main app’s socket, so the share sheet opens its own short-lived connection (purpose: share), sends its files, and hangs up. The Mac serves it alongside the session: it never becomes the primary, never changes the reported connection state, and its arrival and departure leave the standing link untouched. Before this lane existed the receiver held exactly one connection — so 分享 failed precisely when the main app showed “connected.”
Hanging up needs its own message
One more piece of semantics that is easy to miss: a closed socket carries no intent. A crashed Mac, a sleeping Mac, a Wi-Fi dropout, and a user deliberately clicking “disconnect” all look identical from the phone: the connection died. And the phone’s reconnect policy (exponential backoff — 1 s doubling to a 15 s ceiling, giving up after 12 attempts) exists precisely to reconnect through the first three.
Which means the user clicks “disconnect phone” on the Mac, and the phone’s own backoff quietly undoes it within seconds. The fix is a bye frame, sent just before the Mac closes the socket on purpose, carrying a reason: user (deliberate disconnect, pairing intact, stop reconnecting) or unpaired (the pairing token was rotated; reconnecting can only be refused). bye is best-effort by design: it is never sent to an unauthenticated peer, and a phone that misses it simply falls back to the old behavior — reconnect, get refused at the gate — which is safe, just less polite.
The verification script lied to me twice
The gate exists; how do I know it holds? An external Python script (verify-handshake-gate.py) plays a hostile peer against a running receiver: clipboard.offer with no pairing, files pushed with no pairing, a hello with a wrong token, a hello with no token, a peer that connects and says nothing — each asserted to end in a dropped connection. Given the real token it also checks the paired path, and that a share connection is served while a session stays up.
The interesting part is that this script itself was wrong twice — and both times the test results were the exact opposite of the truth:
First: every case FAILED while the receiver was flawless. The early script spoke plaintext TCP. A TLS server answers the first plaintext bytes with a fatal alert — and to a plaintext socket, that alert is data. The script saw a non-empty response, concluded the hostile peer had been served, and failed every case loudly. The receiver’s behavior was impeccable; the probe was speaking the wrong language. The fix: the script must speak the real transport — TLS, with verification deliberately off, because pinning is the phone’s job and this script tests the gate above TLS; demanding a trust chain that by design does not exist would only stop the test from running.
Second: every case PASSED while every real client was being rejected. An earlier version of the script built frames with standard UUIDs and whole-second timestamps — but the HarmonyOS client generates ids of the form millis-hexrandom, with millisecond timestamps. The script’s “hostile” frames sailed through while the real phone’s frames were all refused, because the frame encoder had drifted from the real client’s. The fix: the script’s encode_frame mirrors the phone’s makeEnvelope() in Protocol.ets field for field, with a comment recording the history so nobody “cleans it up” back to standard UUIDs.
Together they make one complete lesson: test code that plays the attacker must first be a faithful client. Speak the wrong language and you are testing your own probe; drift the frame format and you are admitting a client that doesn’t exist. Both failure modes render a green test run meaningless — and neither announces itself, because the tests ran.
What I took away
- An authentication gate’s output surface should be zero. Before authentication, not one byte goes back — not even a refusal. Every response is free intelligence for whoever is scanning.
- A rejection’s blast radius should be one. A bad credential kills its own connection and nothing else. A session that a stranger’s garbage frame can take down is a denial-of-service published as an API.
- Intent must travel explicitly. “Disconnected” and “disconnected on purpose” are indistinguishable at the socket layer, and auto-reconnect will faithfully undo the user’s deliberate action — the only way to distinguish them is a message in the protocol that carries the intent.
- Adversarial tests live or die on fidelity. The hostile peer must speak the real transport and emit byte-faithful frames, or it verifies nothing but its own imagination.
Comments