DEV Community

Cover image for URTB: An Encrypted PTY Tunnel Over ESP-NOW and LoRa
Nenad Mićić
Nenad Mićić

Posted on

URTB: An Encrypted PTY Tunnel Over ESP-NOW and LoRa

transport_modes.png
I have two personal laptops: a MacBook Air I carry around the house, and an old Lenovo that mostly stays in the garage. They sit on different VPNs. When WireGuard testing cuts me off I still need a shell on the other machine, and carrying a 5 kg laptop around the house is not practical.

A pair of Heltec WiFi LoRa 32 V3 boards, plugged into USB on each laptop, solve that problem. URTB is the host binary and matching firmware that give me an encrypted interactive shell over ESP-NOW, with LoRa as automatic fallback.

What it is

Two urtb processes, each holding the same passphrase-protected capsule file, establish an XChaCha20-Poly1305 session and carry a PTY shell between them. The primary transport is ESP-NOW. In my indoor tests on a clear 2.4 GHz channel, that meant roughly 1-2 Mbps and sub-5 ms latency. When ESP-NOW fails, the session continues automatically over LoRa: slower, but longer-range and sub-GHz. No session renegotiation. The same binary also works over a UNIX socket or through an SSH jump host, so you can try it without any radio hardware.

./urtb keygen --out pairing.capsule

# machine A
./urtb listen --transport heltec --device /dev/cu.usbserial-0001 --capsule pairing.capsule

# machine B
./urtb connect --transport heltec --device /dev/cu.usbserial-0002 --capsule pairing.capsule
Enter fullscreen mode Exit fullscreen mode

Two scenarios worth explaining

ESP-NOW primary, LoRa fallback.
LoRa is useful as an emergency channel. I tested Reticulum's rnsh on real hardware. It works, but in my LoRa tests it was doing roughly 200-400 bytes/second and 200-500 ms per keystroke, which makes interactive use painful. top takes 10-15 seconds to redraw. The bottleneck is LoRa's physical layer, not rnsh. ESP-NOW fixes the throughput problem for short range. URTB uses both: ESP-NOW when it is available, LoRa when it is not, with the session staying alive across the switch.

Encrypted terminal through a restricted SSH jump host.
Say you need to reach a server in a DMZ through a jump host you do not fully trust. Only port 22 is open. AllowTcpForwarding is disabled. VPN and Mosh need additional ports. The jump host may be compromised.

URTB has two useful patterns here. The simple one uses --exec: the URTB AEAD-encrypted tunnel rides inside an SSH byte stream and does not need port forwarding, ProxyJump, or any extra listener on the jump host.

For the stricter case, pre-start a listener on the target with --loop and bridge through the jump host with ssh ... socat STDIO UNIX:/tmp/urtb.sock. That keeps the capsule passphrase local to the endpoints.

# target
URTB_PASSPHRASE=example-passphrase ./urtb listen --transport unix \
    --socket /tmp/urtb.sock --capsule cap.cap --loop

# client
URTB_PASSPHRASE=example-passphrase ./urtb connect \
    --exec "ssh jump ssh target socat STDIO UNIX:/tmp/urtb.sock" \
    --capsule cap.cap
Enter fullscreen mode Exit fullscreen mode

The capsule is transferred out-of-band to the target; the jump host is not trusted with it and does not need to persist it. The jump host still relays bytes, but it is not trusted with session plaintext.

Once the capsule is loaded, --burn does a best-effort local wipe and unlink of the key files. After that, key material stays in process memory for the lifetime of the process, with mlock and MADV_DONTDUMP where the platform supports it. Add --otp and the attacker also needs a valid HOTP/TOTP code to open the PTY, even if they obtain the capsule file from another channel.

How I built it

This is an AI-assisted project. The implementation was generated with AI against a frozen specification that I wrote first and then reviewed in multiple rounds with different models before any code was generated.

The process, briefly:

  1. Specification first. About 2,000 lines of markdown across SPEC.md, PROTOCOL.md, SECURITY.md, ACCEPTANCE_CRITERIA.md, and DECISIONS.md before any code was generated.

  2. Adversarial multi-agent review. The spec went through seven review rounds using multiple AI agents with different remits: protocol correctness, crypto audit, numerical consistency, and state-machine verification. The 7th round caught a fragmentation logic contradiction that would have forced a rewrite of the channel multiplexer if it had survived into implementation.

  3. Freeze, then generate. Once the spec was clean, I froze it and pointed the code-generation agents at it.

  4. Implementation review, same method. Multiple blind agents reviewed the code, then a synthesis pass pulled the findings together. The OTP bypass in burn mode (if (s->otp_path) instead of || s->otp_key_mem) was caught this way.

  5. Hardware in the loop. Two Heltec V3 boards stayed connected over USB during development. The models could run end-to-end tests on real hardware. I also had them write failure-injection code: the firmware has a test-inject build that can drop ESP-NOW TX, drop LoRa TX, and simulate link failure on command from the host.

  6. Disposable VM for jump-host testing. Jump-host scenarios were tested against a KVM virtual machine that could be rebuilt on demand via a signed wrapper script. All eight HOWTO_JUMPHOST scenarios were validated end-to-end that way.

What came out

The host binary is about 8,000 lines of C with no dependencies beyond libc and Monocypher. The firmware is about 1,050 lines of C++ (Arduino/PlatformIO). Notable properties:

  • XChaCha20-Poly1305 AEAD, BLAKE2b key derivation, Argon2id-protected key storage
  • 256-entry additive-fencepost replay window
  • PTY multiplexing with fragmentation for LoRa's 72-byte plaintext MTU
  • Automatic ESP-NOW-to-LoRa failover and recovery without session renegotiation
  • LoRa duty-cycle-aware batching for the EU 868 MHz 1% limit
  • Optional HOTP/TOTP second factor
  • --burn: best-effort local wipe and unlink of capsule and OTP key files after load
  • Signal handlers that wipe PSK from memory on SIGTERM, SIGHUP, SIGQUIT, and best-effort on SIGSEGV/SIGBUS/SIGFPE
  • MADV_DONTDUMP to exclude key material from core dumps on Linux
  • Landlock + seccomp sandbox profiles for both connect and listen modes
  • 42 acceptance criteria passing, 8 failure-injection tests passing, CI on GitHub Actions

I ran cat /dev/urandom through the full stack — radio, USB framing, AEAD, reassembly — for 20 minutes without a crash. That was the test that satisfied me the code was not just correct on the happy path.

Limitations

Worth being explicit about what this is not:

  • Not audited. This is a personal project. The security surface was designed carefully, but it has not been reviewed by an independent security firm.
  • LoRa is very slow. In my tests it was around 200-400 bytes/second. Short commands work; anything that generates significant output needs the throttling mode or the session becomes sluggish.
  • Not a VPN. No IP routing, no general port forwarding. One encrypted PTY session between two named processes.
  • No general file transfer yet. This is terminal-first.
  • --burn is best-effort. On SSDs and modern filesystems, overwrite-before-unlink is not a guarantee of non-recoverability. It reduces exposure from filesystem access after the process exits; it is not cryptographic erasure.
  • Compartment sandbox is optional. The Landlock + seccomp profiles in compartment/ are not enabled by default. They require a separate tool ( compartment , also mine) and manual profile activation.

Repository

The project is public at github.com/nmicic/URTB. You can try it without hardware using --transport unix; the quick start in the README takes about 30 seconds once dependencies are installed. The name stands for USB-Radio Terminal Bridge.

The code is AI-assisted and I am not hiding that. The specification, the review process, the acceptance criteria, the testing methodology, and the decision to ship or not ship — those are mine.

Jump Hosts

Top comments (0)