████████╗ ██████╗ ███████╗ ██████╗██╗ ██╗ ██╗ ██╗ █████╗ ██╗ ██╗
╚══██╔══╝ ██╔══██╗██╔════╝██╔════╝██║ ██╔╝ ██║ ██║██╔══██╗╚██╗██╔╝
██║ █████╗██║ ██║█████╗ ██║ █████╔╝ ███████║███████║ ╚███╔╝
██║ ╚════╝██║ ██║██╔══╝ ██║ ██╔═██╗ ██╔══██║██╔══██║ ██╔██╗
██║ ██████╔╝███████╗╚██████╗██║ ██╗ ██║ ██║██║ ██║██╔╝ ██╗
╚═╝ ╚═════╝ ╚══════╝ ╚═════╝╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝
[ t - d e c k f i r m w a r e : : w r i t e u p ]
Cracking the MeshOS T-Deck Firmware
// Three rounds against the LilyGO T-Deck firmware: v1.3.0, then v1.3.1 and v1.3.2 as cosmetic hotfixes. ESP32-S3 firmware reverse-engineered from the .bin via esptool, the libsodium-static verifier identified by argument-register convention, the embedded public key located by following x4 (Xtensa ABI fourth-arg) back to its load. Rounds 4 and 5 were diff-only.
Seven T-Deck firmware rounds. Round 9 (v1.3.8) is current and is a single paragraph — he changed nothing of substance. Round 6 (v1.3.4) is retired: we never fully stabilised the patch against intermittent post-activation crashes before v1.3.5 dropped. The earlier rounds (5, 4, 3) are collapsed underneath; Round 3 (v1.3.0) is the substantive walkthrough. The Companion APK rounds are in the sister article.
Round 9: v1.3.8 — offsets shifted +0x118. that's it. (patch this)current
✓ Confirmed. Same keypair as v1.3.6. v1.3.6 keys still activate v1.3.8 unchanged. build on the page.
The whole round, in one paragraph
The licence-DROM cluster slid +0x118 (+280 bytes). The SEAL slid −0x28 (it lives in a different region). Image grew 17 KB. Every secret value is byte-identical to v1.3.6: same 16-byte random-binary SEAL (840e5fd8…41a4), same XOR masks (0xA9 / 0xB4 / 0x14), same boundary (low 24 / high 8), same off-curve-R tamper canary, same developer pubkey. Patch this: XOR-0x14 SHA envelope at 0x2a768 (32 B), XOR-0xB4 high block at 0x2a788 (8 B), XOR-0xA9 low block at 0x2a790 (24 B). Leave the canary at 0x2a71c / 0x2a728 alone — same reason as v1.3.6, it expects a verifier-reject. Recompute the segment XOR checksum at the new aligned position and the appended image SHA-256. 97 bytes change total. Done.
About that "release"
You let the binary grow naturally for two weeks and shipped the result. No source-code change to the licence side. Same SEAL bytes, same XOR masks, same boundary, same canary. The only thing you changed was the file size, which moved every offset our patcher hardcoded by an amount that's trivial to derive from a structural anchor search. From your writeup's own characterisation of the move: "the cheapest possible defender move" — well, yes. It costs you nothing and it costs us five minutes of re-running the offset hunt. The patcher now uses structural-anchor search (find the XOR-encoded pubkey by brute-forcing single-byte masks, find the SHA envelope by computing SHA(SEAL || DEV) ⊕ 0x14 and grepping for the 32-byte window) so the next time you do this, the patcher just — works.
Did we finally break your soul? You had time to rotate the keypair. You had time to add a second integrity envelope. You had time to do literally anything that wasn't sliding offsets. You shipped a binary that grew. Round 10 is welcome any time — with a new idea in it, please. Salad's getting warm.
Round 8: v1.3.6 — new SEAL, new XOR keys, new tamper canary, same conclusion (no writeup, but a long letter)superseded
✓ Confirmed working on hardware — build on the page.
Activates clean and rides through the periodic tamper-canary sweeps without incident.
v1.3.6 rotates secrets that v1.3.0–v1.3.5 had kept constant. The 16-byte SEAL is now random binary bytes instead of "MESHOS_SEAL_V133". The three XOR keys (low-half mask, high-half mask, SHA-envelope mask) all moved. There's a brand-new periodic integrity check that runs from a foreground task every 300–900 seconds with a clever tamper canary — a deliberately-malformed Ed25519 signature whose R point isn't on the curve, designed to brick the device if someone hooks the verifier to always-succeed. Cute. Doesn't help.
What's interesting (briefly)
The tamper canary is the one piece of v1.3.6 that's actually a step up over v1.3.5 in a way worth a sentence. It exploits the fact that the obvious cracker move — "hook the verifier function to return true" — would also succeed on the canary's malformed signature, which a legitimate verifier rejects. So a hooked verifier passes every real licence and fails the canary; three failures and the device sets a persistent tamper flag and enters a permanent reboot loop. It's actually a nice piece of defensive design and we'll give credit where it's due.
It also doesn't help, because we're not hooking the verifier. We never were. The whole point of the attack family we've been running for ten rounds is we don't touch the verifier; we change the constants it consults. The canary fires on hooks, not on substitution. Same Ed25519 verifier, same canary semantics, same "verifier rejects this malformed sig" return — just running against a different embedded public key than Andy thinks it's consulting. Canary passes. Boot-loop trap stays disarmed. Device runs.
Why no detailed writeup (still)
Same line as Round 7. We published the principle, not the diff, for ten rounds. We're not going to keep narrating new rotations forever. The constants in v1.3.6 are different; the strategy isn't. If a future round contains a genuinely new idea — a new attack surface, a new class of defence, something interesting at the design level rather than the byte level — we'll write it up because there'll be something to learn from. v1.3.6's tamper canary is interesting enough to deserve the paragraph above, but the rest of the round is offset arithmetic that would just hand Andy a list of where to put his next rotation.
How long it took
Less than v1.3.4, more than v1.3.5. The secrets did actually rotate this time, so the "find the new offsets" sweep was meaningful instead of cosmetic. Once the new SEAL bytes and the XOR keys were identified, the rest of the patch was the same shape as every other round. Maybe twenty minutes of actually-paying-attention work, broken up by salad.
Open letter to andy
Picture this, andy.
I'm parked up off-grid somewhere — a mountainside clearing, glacier-fed stream cutting through the grass about thirty yards down the slope, ridge line in the distance, water so clear you can count the rocks on the bottom. The kind of late-afternoon light that makes you understand why people pay for landscape paintings. Base camp set up, Starlink dish propped where it has a clean view of sky. Folding table, camp chair, laptop open in front of me, a fresh caesar salad on the table right next to it, the fish I caught from that stream an hour ago finishing on the fire. I'm half-paying-attention to a couple of work tabs, between bites.
Then — ping — v1.3.6 hits. About twenty minutes of mostly-distracted clicking later, between forkfuls of salad and one truly excellent sip of stream-cold water, the bin's patched and on the page. Then I went back to watching the clouds move across the ridge.
That is the energy this is matching. That is the bar. Give up. You can change it a hundred times. You aren't going to beat me. There's always going to be someone smarter than you with more time and more patience — except I'm not even putting in time. I'm patching your firmware as a tea break. I went back to the fish.
Let's talk about your release cadence, because that's actually the part worth saying out loud. Two cosmetic fixes and a disguised licensing rotation, every other day. Look at it from the outside. Your users open the app, see "v1.3.6 — small improvements" land in their changelog. Then "v1.3.7 — small improvements." Then "v1.3.8 — small improvements." Three times a week. No perceptible difference in what the app actually does. They tune the notifications out. Then they stop opening the app. Then they uninstall — because the only behavior they associate with your name is churn, and a churn-only app feels broken even when nothing's broken.
At this point the only thing you're doing is shrinking your user base and growing mine. Average time from your release-drop to a working community build sitting on the page: roughly fifteen minutes. Average time from a github issue getting filed to it getting closed: — checks notes — mostly “never.” The community mirror is more stable than the paid build, fixes user-visible bugs you ignore, and ships once per Andy-update instead of three times a week. We're not even trying to win on that axis. You're just losing it.
Go through your github issues. Pick ten things actual users have actually asked for. Fix them. Ship a release that means something. The meadow is busy. I have salad to finish.
Sigh. You bore me, andy.
If you really want to beat me, hire a real developer who actually understands the code your agent is spitting out — and honestly, even then, I sincerely doubt it. No amount of compiler-driven repacking, SEAL rotation, tamper canary, periodic-check task, or 3 AM AI-pair-programmed XOR shuffle is going to change the outcome. Round 11 is welcome any time. If it has a new idea in it we'll write it up. If it's another mask rotation, we'll add a row to the scoreboard and go back outside.
Round 7: v1.3.5 — no writeup, you don't deserve one (easier than v1.3.4 was)click to expand
✓ Working build on the page · flasher + activator here. v1.3.5 dropped before we'd finished stabilising the v1.3.4 patch (see Round 6, retired). We diffed it, recovered the new key the same way we always have, rebuilt the patcher, flashed it, and it works. It's easier than v1.3.4 was. Andy quietly dropped some of the meaner anchors that made v1.3.4 cost us a couple of bricked test devices. The round collapsed back to something pretty close to the v1.3.0 pattern. So we're calling Round 7 in a paragraph and moving on.
Why no detailed writeup this time
For five rounds we walked through what changed and how we worked around it, in detail, with reasoning. We did that because each round had something genuinely worth documenting — the Flutter AOT walkthrough in v1.0.0, the Ed25519-public-key-swap principle in v1.0.2, the XOR-hidden snapshot encoding in v1.0.3, the Xtensa narrow-immediate encoding constraint in v1.3.4, the five-anchor / dual-verifier / SHA-256-tamper-anchor pattern, all of it. A lot of that is general firmware-reverse-engineering and licensing-design literacy that's useful far beyond MeshOS, and the writeups are mostly aimed at making that material available to anyone who wants to learn from the round.
v1.3.5 doesn't add anything to that. It rotates some constants and quietly removes one of the harder defences v1.3.4 had. Documenting it line-by-line would mostly be telling Andy what we noticed and where his next rotation should land. We've been generous about that for ten rounds across two products; we're not anymore. The bin is on the page, the in-browser flasher + activator does the work, end users get what they need. The patch methodology is the same as Round 3 (v1.3.0) with the same caveats we documented there, applied against the new build.
This isn't a new policy — it's the same line we've drawn since v1.0.2: we publish the principle, not the diff. What's different about v1.3.5 is that this is the first round where there's no new principle either, so there's literally nothing for the writeup to be about other than narrating the patcher's offset updates. No.
You actually made this version easier than the last one, andy
Worth saying twice. v1.3.4 had: a primary verifier, a cache-aware secondary verifier with its own key reconstruction, two XOR'd DROM blocks with different masks, a SHA-256 integrity anchor wired into a three-strike permanent boot-loop. Each of those layers cost us time and one of them almost cost us hardware. v1.3.5 strips a couple of those back out. Whether that's intentional ("the boot-loop trap was too aggressive, somebody's going to brick a real user") or accidental ("I forgot which release branch I was on"), the net effect is that v1.3.5 took less work than v1.3.4 did. Round 9 was, in elapsed wall-clock time, somewhere between v1.3.2 and v1.3.4 in difficulty — closer to v1.3.2. So if the goal of the relentless point-release cadence is "make it harder for the community each round," the trendline went the wrong way this time.
Free advice, since we're here
This is going to sound like a roast and partly it is, but it's also genuine: you can hold off on the relentless point releases. Five T-Deck builds since v1.3.0, plus four APK builds, and almost every "fix" of the last several has been cosmetic licensing changes wearing a "v1.x.y → v1.x.(y+1)" badge. You're not going to beat us with constant-rotation patches. Each one takes us between minutes and a few hours and the next round arrives before we've finished writing up the last one.
Meanwhile your github issues have actual user-reported bugs in them — the kind of thing that, if you fixed them, your customers would notice. Disguising licensing-tweak releases under bug-fix point-release version bumps isn't fooling anyone, it's just turning the changelog into spam. Genuinely consider sitting on the licensing rotation for a release or two and shipping meaningful improvements. Holy hell, that has to be annoying as a paying consumer — "v1.3.5 release notes: small improvements" landing weekly with no user-visible change because the change is a swapped XOR mask on a public key they'll never see.
Round 10 is welcome any time. If it contains something interesting we'll write it up. If it's another mask rotation we'll add a row to the scoreboard and move on.
Round 6: v1.3.4 — five-anchor key swap (RETIRED — never fully stabilised) (retired)click to expand — historical
⚠ Retired round. v1.3.4 was the hardest T-Deck round so far — the five-anchor / dual-verifier / SHA-256-tamper pattern documented below was real work and the writeup below is preserved as the substantive technical record. However, the patched build had intermittent crashes in normal post-activation use that we never fully diagnosed before v1.3.5 shipped. We're not going to chase a fix into a retired version. If you're somehow stuck on v1.3.4, the bin is in the older-versions archive on the homepage; the writeup below is the same as it was when it first went up. For everyone else, use v1.3.5 (Round 7).
What changed in v1.3.4
v1.3.4 is the round where we got a closer look at the T-Deck firmware's internal layout, and it turns out Andy's firmware-side defence is meaningfully more interesting than the APK side. The community v1.3.4 build on the page uses the same core idea we've used since v1.3.0 — swap Andy's verifying public key for one we control — but the firmware's storage of that key forced one wrinkle into the build pipeline that's worth explaining, and that's why a fresh community keypair is paired with this round.
The thing that's interesting about the firmware
Until this round we'd characterised the T-Deck side as "the public key sits in a 32-byte run in .rodata; locate it, overwrite it, recompute image checksums, done." That's roughly right but not quite. The firmware doesn't actually store the verifying public key as a single contiguous run anywhere. It splits it across two storage modes:
- The first chunk lives baked into the verifier's machine code as instruction immediates. A short straight-line sequence inside the verifier function stores those bytes onto a stack buffer one at a time, with each byte appearing as the immediate operand of an Xtensa
movi(or its compact formmovi.n). Disassembling the verifier produces something that reads like a hand-rolledmemcpyfrom constants — load constant into a register, store it touStack_94 + i, repeat. The constants themselves are the front of the key. - The remaining chunk lives XOR-masked in a small block of
.rodata. A separate loop reads that block, XORs each byte against a single-byte mask, and appends the result to the same stack buffer the immediates wrote into. The block on disk looks like noise; only the XOR-decoded version is the real bytes.
After both halves run, the stack buffer holds the full 32-byte key the libsodium-style verifier consumes. From the device's perspective nothing exotic is happening — it's a perfectly ordinary Ed25519 verifier with the key materialised at run-time. From an analyst's perspective, the key bytes don't appear anywhere on disk as a contiguous run. No grep for any candidate value finds it; no single-byte XOR sweep finds it. You have to disassemble the verifier, line up the immediate-load instructions in source order, and concatenate them with the XOR-decoded .rodata block.
That's a noticeable step up in defensive cleverness over the APK side, which through v1.0.4 stores the same key as 32 contiguous bytes XOR'd with a single per-byte constant. The T-Deck design takes the "key is bytes in writable storage" property and partially answers it: half the key isn't in bytes-on-disk-as-data at all; it's in bytes-on-disk-as-the-immediate-fields-of-load-instructions.
How the swap still works anyway
The split storage doesn't change the conclusion, only the procedure. The verifier still has to reconstruct the full key in a register at the moment of the check, and any builder who can re-emit the verifier's instruction stream and rewrite the .rodata block can choose which bytes get reconstructed.
For the immediate-store half: each load instruction's immediate field is a small region of the encoded instruction, and rewriting it from "load 0x__ into the register" to "load 0x__ into the register" with a different constant produces a same-size instruction that runs the same way, just storing a different byte to the stack buffer. For the XOR'd half: rewriting the .rodata block bytewise (with each new byte pre-XOR'd against the mask) makes the decode loop produce our chosen bytes. The verifier's call flow doesn't change. No instructions are inserted, removed, or moved. Recompute the ESP-IDF segment checksum and the trailing image SHA-256, and the bootloader accepts the resulting firmware as if Andy had built it.
Why we rotated the community keypair this round (it isn't about opsec)
Here is the part that surprised us. The Xtensa LX instruction set has two encodings for the immediate-load instruction the firmware uses for the front half of the key:
- The wide form (
movi): three bytes. The immediate is sign-extended from 12 bits, which means it accepts any 8-bit value. - The narrow form (
movi.n): two bytes. The immediate is a signed 7-bit value, which means it only accepts byte values that fall into a particular ~96-value band of the 256-byte space.
The Xtensa compiler emits the narrow form whenever the constant fits its range, and the wide form otherwise. That's a per-instruction choice, made at compile time, baked into the instruction stream. It means the size of the verifier function depends on which specific byte values appear at which positions of the key.
Andy's developer key happens to have a handful of byte positions whose values fall into the narrow band — for those positions the compiler emitted the two-byte movi.n. The remaining positions got the three-byte movi. If we want to substitute our public key in place without changing the size or shape of the verifier function, we need our key to satisfy the same per-position fit-pattern: at every position where Andy's compiler picked the narrow form, our key's byte at that position must also fall into the narrow band. At every position where the compiler picked the wide form, our key can have anything.
The number of constrained positions is small (a handful out of the front 13 bytes), but the bytes of an Ed25519 public key are effectively uniform random. Each constrained position has roughly a 96/256 ≈ 37.5% chance of being in range for a random keypair. With n constrained positions, the probability of a random keypair fitting all of them is (96/256)n — on the order of one in a few hundred for the v1.3.4 fit pattern.
The alternative is to grow each narrow load to a wide load and reflow the entire verifier function plus any spanning branches and PC-relative literal references inside it. That's not impossible, but it's the kind of invasive surgery where a single off-by-one produces a bricked T-Deck rather than a deactivated licence, and a bricked T-Deck has no obvious in-field recovery path. We chose the cleaner option: generate random Ed25519 keypairs in a small loop and accept the first one whose public key satisfies every position constraint. A few hundred attempts on average; in practice we landed one quickly.
So that is the actual reason the v1.3.4 community keypair is different from the v1.3.0–v1.3.2 community keypair. It isn't opsec. It isn't compromise. The longer-running community keypair we'd been using simply didn't satisfy the position constraints the v1.3.4 verifier's narrow-immediate encoding imposes on candidate keys. So we ran the keypair search, picked one that fits, repointed the keygen API at the new private seed, and tagged it v2 in the api routing.
The new keypair is cryptographically no weaker than the old one. The constraint we apply selects a subset of the Ed25519 keypair space containing roughly 2252 / a few hundred ≈ 2243 keypairs — vastly more than any known attack against Ed25519 needs in its security margin. The constraint is purely a fit constraint against a particular instruction encoding's immediate range; it has nothing to do with the underlying curve arithmetic.
What we missed on the first pass (or: how we boot-looped a T-Deck)
The first build we shipped patched two things: the front-of-key immediates in the verifier and the back-of-key XOR block in .rodata. On the bench it looked fine — esptool image_info reported both integrity values valid, the device booted, the activator round-tripped, and the screen said "License activated!". Then we flashed a different test device and watched it cheerfully boot-loop into oblivion. A third device showed "activated" but with every feature gate silently closed. The first build was wrong in three different ways at once, and untangling that gave us a much better picture of how Andy's firmware-side defence actually works. Worth writing up because each layer is the sort of thing you only see once you go looking for it.
A second verifier, hiding around the corner
The function we'd been calling "the verifier" is the one the on-screen Activate UI and the /license set CLI command call. We patched that one. What we hadn't noticed was a sister verifier sitting a few hundred bytes earlier in the binary — same algorithm, same 13 immediate-store instructions reconstructing the front half of the key, same XOR-decode of the back half — only differing in which register it materialises the key into and in that it caches the result in DRAM. The on-screen Activate path calls the primary; every feature gate at runtime calls the cached secondary. Patch only the primary and you get the world's most thorough lie: the screen reports activated, the cached "activated" flag is persisted to NVS, and then the moment any actual feature consults its gate the secondary reconstructs Andy's original public key (which we never touched), fails the Ed25519 check against our signature, and quietly returns "not activated". The UI never finds out.
That's a genuinely clever design pattern on Andy's part, and we mean that without irony. Caching the verification result on the hot path is a good idea on its own merits (Ed25519 is not free, and feature gates fire constantly). The fact that the cache lookup is implemented as a second full verifier rather than a flag read is what makes patching only the primary blow up so spectacularly — you'd think you were done. We weren't. Same 13-immediate edit applied a second time, register swapped from one general-purpose register to another, and the secondary now reconstructs our key as well.
A duplicate copy of the front-of-key bytes hiding in .rodata
The front-of-key bytes — the ones the two verifiers reassemble from instruction immediates — also live in .rodata as a separate XOR-masked block. Different mask byte from the one used for the back-of-key block. The duplicate copy is read by an integrity check and by a long initialisation routine; neither verifier touches it. If you patch only the verifier immediates and leave this DROM duplicate alone, every part of the firmware that consults the integrity check ends up reassembling a Frankenstein pubkey — the first 13 bytes from this still-Andy-flavoured duplicate, the last 19 bytes from our patched XOR block. Which means the integrity check produces a hash over a key that doesn't match anything that actually exists.
Small aesthetic note: the mask byte Andy chose for this duplicate-block XOR is the same constant his v1 Android APK used for its whole-key XOR. He's recycling his own tricks across products. Once you've seen it on one platform you spot it on the other immediately. We could not stop laughing about this for at least an hour. It's the same XOR mask, andy.
A SHA-256 anti-tamper anchor (and a three-strike reboot path behind it)
Adjacent to the two DROM blocks is a single-byte "magic" value that's the actual integrity check. The firmware reassembles the pubkey from both DROM blocks (not from the verifier immediates), runs SHA-256 over a fixed string concatenated with the reassembled key, takes the first byte of the hash, XORs against a stored constant, and compares to a known sentinel. If the comparison fails, an integrity flag is set.
That flag feeds into the boot path through a three-strike reboot handler. First failed boot: increment counter, continue. Second failed boot: increment, continue. Third failed boot: set a persistent tamper flag in storage and jump to the reboot routine. The flag survives reboots, so the device then reads tamper=1 → reboot → tamper=1 → reboot. A perfect little uninterruptible boot loop. There's no UI affordance to clear the flag — recovery is "wipe storage and re-flash."
This is the meanest thing in the build, and again we mean that as a compliment. The classic developer instinct is to ship a tamper detector that logs a warning or refuses to start the licence subsystem. Andy went all the way to "permanently brick this device after three boots." You only learn about the three-strike behaviour by experiencing it; the integrity check itself is silent on success and silent on failure. Three boots later the device is gone. For an attacker without spare hardware, the cost of getting any one of these anchors wrong is the device.
The fix once we'd located the anchor was mechanical: rebuild the pubkey we'd want the integrity check to reassemble, compute the corresponding SHA-256 byte, and update the magic to match. One byte of patch, plus the duplicate-DROM patch from the previous section so the integrity check reassembles a coherent key in the first place.
NVS retains the previously-issued licence (and lies about it)
Even with all five anchors patched, devices that had previously been activated under Andy's key kept failing in confusing ways. The cause: the non-volatile storage partition retains both the cached "activated" boolean and the full base-64 of the legacy licence string. On boot the firmware reads the cached licence and runs it through the (now-patched) secondary verifier — which checks the legacy signature against our public key, fails, and returns closed. Meanwhile the UI has already loaded the cached "activated" flag as a boolean and rendered the screen accordingly. Same "says activated but isn't" symptom as the dual-verifier issue, but a different root cause.
The clean answer is to flash the merged image rather than the standalone OTA app. The merged file (linked from the page) carries the bootloader + partition table + an empty-state NVS prefilled with 0xFF + the patched app, all in one shot. When esptool write-flash writes the merged file at offset zero, it erases each flash sector it's about to write to first, then writes the file's bytes — including the empty NVS region. The end result is identical to a separate erase-flash on the NVS partition, but without the user having to know the partition layout or run a second command. Stored mesh identity, contacts, message history, etc. live in a different partition outside the merged file's range and survive the flash untouched.
The complete patch set
So Method A on the T-Deck firmware is a five-anchor edit, not the two we initially shipped:
- The primary verifier's 13 immediate-store instructions, rewritten to materialise the front of the new key.
- The secondary cache-aware verifier's 13 immediate-store instructions, the same edit with a different destination register. Missed first time around.
- The back-of-key XOR block in
.rodata, rewritten with our bytes pre-XOR'd against its mask. - The duplicate front-of-key XOR block in
.rodata, rewritten with our bytes pre-XOR'd against its (different) mask. Missed. - The one-byte SHA-256 integrity magic, updated to match the new key. Missed, and the most expensive miss because of the three-strike boot loop.
Plus the standard ESP-IDF segment-XOR checksum and the appended whole-image SHA-256, both recomputed by the patcher after the five edits. The patcher also does a round-trip check — reads the patched file back, runs the same reassembly the firmware does (immediates from the two verifiers, both DROM blocks XOR-decoded, all concatenated), and asserts the recovered key matches what we intended before writing the file. That round-trip is the difference between "lab works" and "field works", and we put it in after the first round of boot-loops convinced us not to trust visual inspection.
What it cost to find each anchor
None of the five anchors is structurally hard. Each one is in plain sight if you go looking. Specifically:
- The secondary verifier was findable by listing every function in the binary that contained the same string of immediate-load instructions the primary uses. One
grepin the disassembly, basically. - The duplicate XOR block was findable by xref-ing every code site that loads a pointer to anywhere inside the few hundred bytes around the back-of-key block.
- The integrity magic was findable by tracing every function that reads either of the two DROM blocks and looking at what it does with the result.
- The NVS issue was findable by reading the firmware's licence-persistence code, or empirically by noting that flashing-onto-clean-devices worked and flashing-onto-previously-activated-devices didn't.
So none of these would survive a thorough first pass with the right hypotheses. We had the wrong hypotheses. We assumed (a) one verifier, (b) one storage of the key in DROM, (c) no application-level anti-tamper. All three of those were wrong, and the cost of being wrong was a chunk of debugging time plus a couple of devices we had to walk through full reflashes on.
The defence is, structurally, the same one v1.3.0 already had: a verifier reading bytes from writable storage. Andy spread the bytes across more places, added a second verifier as a cache, and wired a tamper check into the boot path. Each layer is mechanical to find once you know to look, but the layered design means you can't shortcut to "the patch" from "the analysis" the way you can on the APK side. Some of these patterns we hadn't seen before this round. We expect we'll be looking for them on every firmware round from now on. Props to Andy, genuinely — this was the round where firmware-side defence stopped being a footnote.
User-side flow
None of the encoding constraints or the five-anchor business leaks into anything users see. The activator on the page sends a keypair=v2 hint to the API for v1.3.4-and-later T-Deck firmware; the API picks the matching private seed; the user gets back the same shape of activation key they'd have got under v1, generated against the new keypair. The connect / get-key / activate steps are identical. Older callers that don't pass a hint fall back to keypair=v1 for backward compatibility with v1.3.0–v1.3.2 community builds.
Effort
The first build went out wrong; the corrected five-anchor build — the one on the page now — landed after a round of "wait, why did it boot-loop?" plus the patcher rebuild. Activation now works on real hardware: flash the merged image, paste a generated key into the licence prompt, watch the screen confirm activation, exercise a feature gate to make sure the cached secondary verifier is also satisfied. All five anchors hold under fresh flashes; previously-activated devices come up clean as long as the merged image (not the standalone OTA app) is used, because the merged image's empty NVS region wipes the cached legacy licence as a side effect of the flash.
Where we are right now: hold off. Beyond the initial activation, testers are reporting intermittent crashes in normal use that we haven't reproduced reliably on the bench. The crashes are downstream of the five-anchor patch (the cached secondary verifier is running and returning true, so the gates are open) but something the firmware does after a successful licence check is occasionally faulting. Could be us — a side effect of the patch we haven't traced; could be Andy — an unrelated bug in v1.3.4 that exists on stock too. We don't know yet. The build is safe to have on a device (no risk of bricking, mesh identity / contacts / message history are in a partition the flash doesn't touch), but we're not recommending it for daily-driver radios until we have a diagnosis. Tester chat is the fastest channel for crash dumps and console output.
Net time to crack v1.3.4 end to end (activation), including the bricked-device tangent: about two hours. Net time to actually ship a stable build: TBD. Andy earned this one — the two-hour budget would have been three rounds back without the layered defence, and the crash-diagnosis tax means this round isn't over yet.
Round 5: v1.3.2 — the kettle didn't boil (90 seconds)click to expand — diff-only
We said in Round 4 that round 5 would be over before the kettle boils. We were not exaggerating. Andy shipped v1.3.2 two days after v1.3.1. We pulled it, ran cmp against v1.3.1, and the diff was effectively nothing — a handful of bytes in cosmetic regions of the firmware (build metadata, version string, an unused-looking padding area). The Ed25519 public key sat at the same offset. The verification call site was at the same address. The libsodium-static library section was byte-identical to v1.3.1's.
From an analysis standpoint, nothing about the v1.3.0 understanding changed for v1.3.2. Total time on our side: 90 seconds — the SHA-256 calculation and the deploy. There was no reverse-engineering to do. The community firmware on the page works with the same Web Serial activator and the same keygen API as the previous T-Deck rounds.
What's striking is the pace on Andy's side. v1.3.0 to v1.3.2 was four days. Three firmware releases in four days, each producing a keygen'd community build within minutes of dropping.
Round 5 is a footnote. The interesting T-Deck work is Round 3 below.
Round 4: v1.3.1 — first hotfix (3 min)click to expand
What Andy changed in v1.3.1
v1.3.1 dropped two days after v1.3.0. cmp -l v1.3.0.bin v1.3.1.bin | wc -l showed roughly two dozen byte differences. bsdiff sized the patch at under 200 bytes. Inspecting the differences:
- The version string in the build metadata segment changed from
"1.3.0"to"1.3.1". - A small block of bytes in what looked like compiler-pad / unused-rodata shifted by a few positions — cosmetic, no functional reference.
- The build timestamp in the image header (ESP32 firmware images embed a build-time integer) advanced by a couple of days.
- One short region near the licence-check function had a few NOPs added — possibly to misalign whatever decompiler Andy assumed we were using. No effect on the actual call flow; the Ed25519 verification is still there, still called the same way, still loads the same 32-byte public key from the same
.rodataaddress.
Nothing in the diff touches the public key location, the libsodium-static section, or the function dispatching the verification. The v1.3.0 analysis applied to v1.3.1 unchanged — same call site, same key offset, same Xtensa ABI register conventions.
What this told us about Andy's strategy
v1.3.1 was the first round where Andy's response to a working keygen was a cosmetic hotfix. The v1.3.2 release two days later was the same shape; the v1.0.3 APK round shortly after did add real obfuscation — see the sister article for that one.
Time to crack: 3 minutes. End-to-end. The same activator works against v1.3.0 or v1.3.1 community firmware on the page; users don't need to do anything different.
(Update: see Round 5 above. The kettle didn't boil.)
Round 3: v1.3.0 — first T-Deck firmware (10 min)click to expand — full firmware-patch walkthrough
The Companion APK track was the easier half. Phones are general-purpose computers: sideload the APK, paste a string, done. The T-Deck is the harder half — a battery-powered LoRa handheld with no on-device sideload UI, no app store, just a USB-serial console and whatever firmware the manufacturer (or the project) chooses to flash. Andy's v1.3.0 firmware tightened the activation flow on that side: better entropy in the device serial, and a verification path that actually does its job.
Same shape as the v1.0.2 APK, though. A device with a bootloader, a console, a license screen, an embedded public key. Whatever the firmware checks against, the firmware also has to contain. Whatever it contains, somebody can read. Whatever can be read can be replaced.
Getting the firmware
Andy ships the T-Deck firmware as a single .bin file targeting the standard ESP32 partition layout. The binary is the concatenation of (bootloader stub + partition table + application image + OTA-data + filesystem partition + checksums), in the format esptool.py expects. Pulled the official MeshOS-TDeck-1.3.0.bin from Andy's release page and dumped its structure:
$ esptool.py --chip esp32s3 image_info MeshOS-TDeck-1.3.0.bin
Image version: 1
Entry point: 0x......
Segments: 5
Segment 1: len 0x0... load 0x3c000020 (DROM)
Segment 2: len 0x0... load 0x3fc... (DRAM)
Segment 3: len 0x0... load 0x40380000 (IRAM)
Segment 4: len 0x0... load 0x42000020 (IROM)
Segment 5: len 0x0... load 0x40380... (IRAM)
Checksum: 0x..
Hash: sha256:......
For the licence-check work, the segment we care about is the DROM — that's where .rodata lives, including string literals, constant tables, and any embedded byte arrays. The 32-byte Ed25519 public key would be in there if it's anywhere.
Finding libsodium and the verification call
First pass — strings on the firmware bin:
$ strings MeshOS-TDeck-1.3.0.bin | grep -iE "ed25519|sodium|license|activate|sign"
crypto_sign_verify_detached
crypto_sign_ed25519_verify_detached
ge25519_has_small_order
ge25519_is_canonical
sc25519_is_canonical
license.activation_required
license.activate
Activation key (hex): %s
Invalid activation key
...
That confirms libsodium-static is linked in and that the activation flow calls crypto_sign_verify_detached. Same scheme as the v1.0.2 APK, just on the radio side instead of in Dart.
Next, disassemble. The ESP32-S3 uses the Xtensa LX7 instruction set, so the right tool is xtensa-esp32s3-elf-objdump (ships with the ESP-IDF toolchain). To use it against a raw firmware bin, you have to either reconstruct an ELF or aim objdump at the segment offsets directly. The cleaner path is to extract the application segment with esptool and feed that to a tool that understands raw blobs:
$ esptool.py --chip esp32s3 image_info --version 2 MeshOS-TDeck-1.3.0.bin
# gives load addresses for each segment
# carve the DROM segment out
$ dd if=MeshOS-TDeck-1.3.0.bin of=drom.bin bs=1 \
skip=$DROM_OFFSET count=$DROM_LEN
# carve the IROM (code)
$ dd if=MeshOS-TDeck-1.3.0.bin of=irom.bin bs=1 \
skip=$IROM_OFFSET count=$IROM_LEN
With the IROM segment carved, you can find the crypto_sign_verify_detached function by its prologue + recognisable libsodium internals. A quicker heuristic in our case: find the string "crypto_sign_verify_detached" in DROM (it's there as a symbol or assertion text), find the address that references it, walk back to the function's call site.
Locating the public key
libsodium's API is crypto_sign_verify_detached(signature, message, message_len, public_key). Each call site has to materialise four arguments: a pointer to the 64-byte signature buffer, a pointer to the message bytes, the message length, and a pointer to the 32-byte public key. The licence-check call is the only call to this function in the firmware, so once you've found one call instruction, you've found the only one that matters.
The call setup loads the public-key pointer into the appropriate argument register (Xtensa ABI: a4 for the fourth argument). Following that register's most recent load shows the load address — an offset inside DROM. Pull 32 bytes from that address and you have Andy's public key:
$ python3 -c "
import struct
with open('drom.bin','rb') as f: drom = f.read()
key = drom[KEY_OFFSET_IN_DROM:KEY_OFFSET_IN_DROM+32]
print(key.hex())
"
<andy's 32-byte ed25519 public key>
To find the same bytes inside the full firmware (rather than inside the carved DROM), search the full bin for that 32-byte run. It appears exactly once. That single offset is the location of the verifying public key inside MeshOS-TDeck-1.3.0.bin — the byte sequence Blutter recovers via the snapshot, the bytes the verifier dereferences at run-time, and the bytes you can confirm in any hex viewer all converge on the same 32 contiguous bytes.
What ties it together
One more piece confirms the scheme: the firmware reuses the same licence-state subsystem the APK side uses, just compiled for the radio. Same Ed25519, same SHA-512, same hex-encoded signature shape on the wire, same activation handler reading from serial. The Flutter-on-T-Deck build pipeline carries the licence subsystem over from the Companion app effectively unchanged at this level; the differences are all in the device-id input (the T-Deck reports a chip ID from the boot console rather than an Android ID) and the persistence layer (NVS partition rather than a Hive box).
The Web Serial activator on the page
The remaining problem on the user side is how to get a signed activation key onto the device without making the user type 128 hex characters by hand. The answer is the in-page activator widget, built on the Web Serial API. The flow:
- User plugs the T-Deck into a desktop computer running Chrome, Edge, or any Chromium-based browser. (Firefox doesn't ship Web Serial. Safari doesn't either.)
- User clicks Connect on the page; the browser shows its native port-picker dialog; the user selects the T-Deck's serial port. From this point the page has a read/write stream to the device at the firmware's console baud rate (115200).
- User clicks Get key. The page writes a query command to the serial port and reads the device identifier from the response.
- The page calls the keygen API on this server with that identifier and gets back a signed activation key as JSON.
- User clicks Activate. The page writes the activation command back over the serial port. The firmware verifies, persists, prints a success message; the page parses the message and reports activated.
End-to-end on a warm browser, three clicks and roughly three seconds. The activator is a drop-in JavaScript module hosted on the page; the keygen API is the same endpoint used by the APK path.
Flashing the community firmware
The community MeshOS-TDeck-1.3.0.bin is hosted on the page with its SHA-256. Flash it with whatever ESP32 tool you already have:
esptool.py --chip esp32s3 write_flash 0x0 MeshOS-TDeck-1.3.0.binfrom the command line- The official MeshCore web flasher (uses Web Serial)
- Andy's own configurator at
meshcore.co.uk/configurator/— yes, the community firmware flashes with Andy's flasher. The irony is intentional.
Total effort (round 3)
Time to crack: 10 minutes. The reverse-engineering portion was minutes — strings + esptool image_info, follow the libsodium call, identify the 32-byte key location via the Xtensa argument register. The bulk of the ten minutes was scaffolding the Web Serial activator widget (port enumeration, the read-loop state machine, parsing the device-specific console output, the UI).
v1.0.0 djb2 keygen (19 min) · v1.0.2 Ed25519 public-key swap (13 min)