~/meshos/writeup/tdeck —   RE  [back: keygen]

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.

Four T-Deck firmware rounds. Round 6 (v1.3.4) is expanded below and is currently on hold — activation works but the build has been crashing in ways we haven't fully diagnosed. Rounds 5, 4 and 3 are collapsed underneath. Round 3 (v1.3.0) is the substantive walkthrough; 4 and 5 are diff-only hotfixes against it; Round 6 (v1.3.4) is where Andy added real defensive depth and the community took a couple of extra hours plus a boot-looped device or two to count all the layers. The Companion APK rounds are in the sister article.


Round 6: v1.3.4 — five-anchor key swap, encoding-constrained keypair (⚠ hold off — crash reports)current

⚠ Hold off — activation works, but the build is crashing for some users. The patched MeshOS-TDeck-1.3.4-merged.bin on the page does flash, boot, accept activation keys from the keygen API, and report "License activated!" with feature gates actually open. However, we're seeing intermittent crashes once the device is in normal use that we haven't pinned down yet — it's not the boot-loop from the first build (that one was fixed by the five-anchor patch) but something downstream. The patched firmware is safe to have on the device — no risk of bricking, no risk to stored mesh identity or contacts — but we're recommending users hold off on flashing it to a daily-driver radio until we've worked out where the crashes are coming from. Testers with spare devices and patience for chasing console logs: please drop in the tester chat, the more crash dumps the faster the diagnosis. The first build we shipped didn't even get this far — we'd missed three anchors on the initial analysis and got boot-looped for our trouble. The story of how the five-anchor patch set came together is the meat of this round, and the crash diagnosis goes in here too once we have one.

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:

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 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:

  1. The primary verifier's 13 immediate-store instructions, rewritten to materialise the front of the new key.
  2. The secondary cache-aware verifier's 13 immediate-store instructions, the same edit with a different destination register. Missed first time around.
  3. The back-of-key XOR block in .rodata, rewritten with our bytes pre-XOR'd against its mask.
  4. The duplicate front-of-key XOR block in .rodata, rewritten with our bytes pre-XOR'd against its (different) mask. Missed.
  5. 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:

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:

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:

  1. 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.)
  2. 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).
  3. User clicks Get key. The page writes a query command to the serial port and reads the device identifier from the response.
  4. The page calls the keygen API on this server with that identifier and gets back a signed activation key as JSON.
  5. 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:

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).

← Back to Part 1: Companion APK (Rounds 1, 2)
v1.0.0 djb2 keygen (19 min) · v1.0.2 Ed25519 public-key swap (13 min)