Implementing a Protocol You're Not Allowed to Read — Re-deriving Mosh from Packet Captures
Official mosh is GPLv3; Shellby is a closed-source commercial app, so reading or copying it was off the table. I re-derived the SSP protocol from the paper plus my own packet captures: every field number came from measurement, guessing one wrong crashes mosh-server outright, and the same protocol had to be byte-identical across Swift and Dart.
SSH runs over TCP, so the connection dies the moment your IP changes. An iOS app gets suspended 20 to 30 seconds after backgrounding, and coming back to the foreground means reconnecting — which means a brand new remote shell, with all server-side process state gone: vim closed, build interrupted.
That is exactly what mosh solves. Its SSP protocol syncs terminal state over UDP rather than a byte stream, and each datagram is independently authenticated, which makes it naturally immune to IP changes and long suspensions. With it wired into Shellby, returning to the foreground on iOS restores the server’s real current state instantly.
One boundary up front: mosh does not make an iOS app stay resident in the background. A normal app still gets suspended. What it buys you is instant recovery with no state loss — not background persistence. That line is a hard rule in our copy: we never claim background persistence.
The problem was that I couldn’t write this protocol by following the official implementation.
A legal wall
Official mosh is GPLv3. Shellby is a closed-source commercial app. Those don’t mix.
The frequently cited COPYING.iOS exception gets misread — more than once I’ve seen it taken to mean “anything goes on iOS.” What it waives is only the conflict between GPLv3 and Apple’s ToS; the copyleft obligation stays fully intact. Blink Shell can use official mosh directly because Blink itself is open source. That’s a different solution under the same rules, not a loophole for closed-source apps.
Which left one path: clean-room implementation.
- Re-derive the protocol from the USENIX ATC’12 mosh paper plus my own packet captures;
- Don’t read or copy GPL source, and don’t copy mosh’s
.protofiles; - Encrypt with AES-128-OCB3. The OCB3 patent was released into the public domain by its author in 2021, so closed-source commercial use carries no patent risk, and RFC 7253’s test vectors are public IETF material and safe to use.
The paper gives you the design. The paper does not give you field numbers. Every wire-format detail had to be recovered from captures.
Field numbers must be measured — guessing has consequences
The layering I derived, innermost out:
protobuf → zlib (standard 0x78 0x9C wrapper) → fragmentation → Fragment header (inside encryption)
→ Packet timestamp header → OCB3 → UDP
Nonce(96b) = 4 bytes 0x00 || be64(direction<<63 | seq) // TO_SERVER=0, TO_CLIENT=1
UDP payload = be64(direction<<63|seq) || OCB3_Encrypt(key, nonce, AD=∅, plaintext)
plaintext Packet = be16(timestamp) || be16(timestampReply) || fragmentBytes
Fragment = be64(id) || be16(fragmentNum | 0x8000 if final) || payload
And the protobuf field numbers that came out of the captures matched none of my prior guesses:
TransportInstruction: 1=protocolVersion (always 2) 2=oldNum 3=newNum 4=ackNum
5=throwawayNum 6=diff(bytes) 7=chaff(bytes)
diff(HostMessage): .1=repeated Instruction → .2=HostBytes → .4=hoststring
UserMessage: .1 → .2=Keystroke → .4=keys
.1 → .3=ResizeMessage → {5=width, 6=height}
Note that ResizeMessage’s width and height are 5 and 6, not the intuitive 1 and 2. This isn’t pedantry — guessing wrong crashes mosh-server outright. With the wrong field numbers, the server parses a terminal size of 0×0 and dies with Error: vector. I actually killed the server twice during debugging before tracking it down.
What makes that class of crash annoying is that it doesn’t look like a protocol problem: all you see is the remote process disappearing, while the UDP layer behaves perfectly. It’s pinned by a test now.
The protobuf codec is hand-written. The good news is that only three cases actually appear on the wire: varint, length-delimited, and repeated field numbers. proto2’s extend doesn’t exist in the wire format at all — it’s equivalent to “a nested message at field number N,” and once you internalize that, the nesting stops being mysterious.
The heartbeat interval was measured too: 3.005 seconds.
One protocol, two stacks, byte-identical
Shellby spans six platforms: the three Apple ones in Swift, and Android / Windows / Linux / HarmonyOS in Flutter. The mosh implementation has to exist on both stacks, and the bytes they produce must be identical — they’re talking to the same mosh-server.
The layering is symmetric:
| Layer | Swift | Dart |
|---|---|---|
| Pure core (zero IO, injected clock and compressor) | Sources/MoshCore/ |
packages/mosh_core/ |
| With IO (UDP / zlib / bootstrap / bridging) | Sources/MoshClient/ |
packages/mosh_client/ |
MoshCore holds Crypto/ (AES128 · OCB3 · Nonce · CryptoSession), Wire/ (Varint · TransportInstruction · HostMessage/UserMessage · Packet · Fragment · FragmentAssembler), and Transport/ (RTTEstimator · ClientCore).
The hard rule is zero IO in the core, with the clock and compressor always injected. That makes the protocol logic a pure function — (state, input, now) → (new state, output effects) — fully drivable by the same fixtures, so both stacks run the same vectors and compare output.
The compressor is injected because of a lesson already paid for: deflate output cannot be guaranteed byte-identical across zlib implementations. Dart’s dart:io bundles Chromium’s zlib, whose level-6 output is one byte shorter than the system zlib. That detail forced a contract change in the cross-stack sync post; here the answer is to have fixtures use an identity compressor, isolating the zlib difference outside what’s under test. Crypto and wire-layer alignment is the thing being verified, and it shouldn’t be polluted by differences in a compression implementation.
Final validation was replaying captures from real mosh 1.4.0: all 47 packets decrypted successfully, zero authentication failures. Only at that point was the derivation trustworthy.
Trademark is the other red line
Beyond the technical constraint there’s one more that bites just as hard: naming.
Mosh is a trademark. Termius was asked by mosh’s author to change its naming in 2017 — a public precedent. So we set a hard rule: user-facing copy always says “Mosh-compatible” (「Mosh 兼容 / Mosh 相容」in Chinese), and never a bare “Mosh.” Internal identifiers like MoshSession are unrestricted; those aren’t user-facing.
Discipline alone can’t hold that line — the copy is spread across xcstrings and ARB files, six platforms, three languages. So the rule became a CI lint: tools/mosh-naming-lint.sh scans every localization resource and fails the build on a bare Mosh. It has earned its keep: first it caught an escapee in the privacy gate copy; later, once the HarmonyOS resource files were brought into its scan, it caught another in the app tagline.
The second one is worth dwelling on — the rule wasn’t being ignored, the scan’s coverage hadn’t kept up with a new platform. Adding a platform hands every global rule a new patch of unchecked territory. A rule’s reach has to grow with the codebase.
There is exactly one deliberate exemption globally: the transport badge in the host and session lists. It’s a small text chip reading SSH or Mosh, and the two appear side by side — in that context it’s plainly a protocol name rather than a product name, and protocol names aren’t translated. It’s implemented with Text(verbatim:) and hardcoded literals, never entering xcstrings/ARB, so it falls outside the lint’s scan by construction. Hover and accessibility text still spell out “Mosh-compatible.”
The rationale for the exemption lives in the lint script’s header comment. An exception to a rule has to be written where the rule is, or six months later nobody remembers why it was let through — and it gets either deleted by mistake or treated as precedent and widened.
Looking back
A legal constraint — “you may not read the source” — ended up shaping the entire engineering method: capture packets, measure every field number, validate interop against a real server.
What’s interesting is that the result isn’t obviously worse. Copying from the source, I probably wouldn’t have learned that a wrong ResizeMessage field number crashes the server, wouldn’t have measured the heartbeat at 3.005 seconds, and wouldn’t have those 47 packets of decryption validation. Being forced to re-derive it from the outside as a black box produced a stronger verification story than “copied it correctly” ever would have.
Comments